
昨天翻自己三年前的笔记看到当年学 HTTP 时画的一张状态码草图底下写了一行字“这玩意背下来也不知道干嘛用的。”如今在 AI 应用开发岗位上我三天两头要跟 200、204、304、429 打交道。转岗做 AI 应用开发第三个月我最大的感受其实是日常用的技术栈课堂上基本都讲过了只不过当时没意识到它们会在这种场景里出现。这篇不是劝退指南也不是转岗鸡汤就是想把我这三个月的真实收支摊开给还在观望的人一些可参考的坐标。如果你正在犹豫要不要转 AI 应用开发或者已经开始面试却对着“技术栈”这个词发怵这篇应该能帮上忙。1. 转岗背景与真实工作内容决定写这篇总结的原因1.1 三个月里我到底在做什么很多人以为 AI 应用开发就是“调大模型 API”天天写client.chat.completions.create然后等模型返回一段漂亮话。真做了三个月我发现这个岗位更像“围绕大模型提供稳定可用的应用服务”。我所在的是中小规模自研公司AI 应用开发岗不是研究院也不是算法组核心目标是把大模型接到产品里让它跑得稳、跑得快、成本可控。我实际的工作内容包括这几个方面接入不同大模型既有 GPT 风格的国际模型也有国内的开源本地模型每个模型的参数名、鉴权方式、返回格式都不一样要做适配层实现流式对话“打字机”效果让回答一个字一个字蹦出来这是现在 AI 产品的基本体验要求做 RAG 知识库问答把公司文档切分、向量化、检索再拼进 prompt 让模型回答给 Agent 加工具调用让模型能调用内部 API、查数据库、操作业务系统处理线上问题消息乱序、回答中断、token 超限、并发扣费、代理超时这些都是家常便饭。所以如果你问“AI 应用开发岗位多吗”至少在中小自研公司里答案是多而且需求还在涨。只是这些岗位往往不会叫“AI 应用开发工程师”而是“大模型应用工程师”“智能应用开发”“AI 平台工程师”这类名字。本质上都是同一个活把模型能力工程化。1.2 “课上讲过”到底意味着什么我转岗前上过几套比较系统的课程包括前端基础、HTTP 协议、数据库设计、算法与数据结构、Python 后端开发。说句实话当时上课的感觉是“学了一堆概念跟做东西隔着一层”。尤其是 HTTP 协议那部分状态码、请求头、响应头背了又忘忘了又背总觉得这是考试用的不是干活用的。转岗三个月后我翻出旧笔记发现一个很扎心的事实当初课上讲的东西大部分都在用。SSE 流式输出就是 HTTP 响应体的一种传输方式text/event-stream的 Content-Type 是课本上提过的前端停止生成按钮本质就是AbortController取消一个 fetch 请求这在前端课程里也有RAG 里的向量检索底层是基于最近邻搜索而数据结构课里讲过的树、图、哈希帮我理解了索引为什么快。所以“课上基本都讲过”不是一句讽刺而是一句感慨知识本身没有过期过期的是我当时“为了考试而学”的心态。真正的工作经验是把这些碎片按新的方式组合起来。我也慢慢意识到AI 应用开发的技术栈并没有互联网上传得那么玄乎它是在传统 Web 开发的地基上多了一层模型交互逻辑。2. 日常技术栈全景拆解AI应用开发岗位的核心组件2.1 SSE流式输出大模型回答“打字机”效果的核心先说最常碰到的 SSE。大模型生成回答通常需要几秒甚至十几秒如果等完整结果返回再展示用户会以为产品挂了。所以现在主流做法是用 SSE 让服务端把生成内容分块推送过来前端边收边渲染。SSE 全称 Server-Sent Events是 HTTP 协议里的“服务器单向推送”机制。它是 HTTP 的孩子不是 WebSocket 的替代品。这里要区分几个概念。传统接口是一次请求一次响应前端发请求后端算半天然后把整个 JSON 一次性返回。SSE 则是一次请求持续推送连接建立后后端可以源源不断向客户端发送文本块直到主动关闭。轮询是前端每隔几秒问一次“好了没”WebSocket 是双向长连接而 SSE 只允许服务器往客户端推数据实现起来比 WebSocket 简单得多且天然支持断线重连。后端实现 SSE 最常用的是 FastAPI 的StreamingResponse设置media_typetext/event-stream。数据格式有约定每条事件以data:开头以两个换行符\n\n结束如果消息结束可以发一行data: [DONE]。我踩过一个大坑SSE 接口联调时本地好好的上线后一到 Nginx 就“卡住”前端像在看慢直播。后来查了才知道是 Nginx 默认会对响应做缓冲等攒够一定字节才发给客户端。解决办法是在响应头里加X-Accel-Buffering: no再配好Cache-Control: no-cache。这是文档里不一定写、但生产环境必须处理的问题。2.2 abort与中断控制停止生成与保命兜底SSE 的“停止生成”按钮说起来很简单用户不想等了点一下前端停止渲染。但要做到真正的“停止”比想象中复杂。前端层面需要通过AbortController中断 fetch 请求后端层面需要感知到连接断开主动取消大模型的生成任务否则模型还在后台跑token 还在烧。我当时的实现是前端创建一个AbortController把它的signal传给 fetch用户点击停止时调用controller.abort()。fetch 收到 abort 信号后会抛出一个AbortError前端捕获这个错误后做出“已停止”的状态切换。注意一个细节reader.cancel()和controller.abort()不太一样。前者是“我读完了把剩下的丢弃”后者是“整个请求作废”。如果你只是想停止渲染但还想让服务端跑完那是另一种做法但 AI 对话场景里用户点停止通常意味着“你也别算了省点钱”所以要两者都断。前端 abort 之后后端的流式连接其实也会断开。但 FastAPI 的StreamingResponse不会自动终止生成器你需要自己处理在生成循环里检查request.is_disconnected()或者用asyncio任务取消机制。否则会出现“用户都关页面了模型还在那里吐字”的情况token 费用照常计费。2.3 AI交互逻辑封装与Agent开发的技术拼图“基于什么技术栈封装 AI 交互逻辑”这是面试里被问得很多的一句话。我理解这个问题的核心是你不能让业务代码到处直接调模型接口要有一层统一的封装。我做的适配层大致包含这些功能统一请求格式把不同模型的messages、temperature、max_tokens等参数归一化统一返回格式不管模型返回什么内部都转成{ role, content, finish_reason }错误处理模型限流、超时、参数非法要转成业务可识别的错误码日志与监控记录每次请求的输入、输出、耗时、token 消耗。Agent 开发的技术栈也绕不开这套逻辑。Agent 的本质不是“自己思考”而是“流程编排”模型判断该调用什么工具→解析出工具名和参数→你的代码去执行工具→把结果塞回对话→模型继续决策。这里面最核心的技术是 tool calling也就是让模型输出结构化的函数调用指令而不是让它自由发挥。我做过一个很简单的工具调用例子给模型注册一个“查询天气”的函数告诉它参数是{ city: string }。模型在对话中判断用户想问天气时返回的不是自然语言而是一个 JSON{ name: query_weather, arguments: {\city\: \上海\} }拿到这个 JSON 后代码负责调用真实天气 API再把结果拼进上下文让模型基于结果继续回答。这一套说起来不复杂但生产环境里要处理的问题很多参数解析失败、工具调用循环卡死、同样的问题反复调用同一工具、上下文越来越长导致 token 费用暴涨。所以 Agent 开发需要的技术栈不只是模型 API还包括 API 设计、状态管理、缓存策略和异常熔断。2.4 传统技术栈最容易被忽视的地基AI 应用开发岗位看似“新”其实超过一半的工作还是传统 Web 开发那套Python 后端、Web 框架、关系型数据库、Redis 缓存、Docker 部署、CI/CD 流程、日志监控。很多课程里学过的“旧知识”在 AI 应用里反而成了救命稻草。比如大模型的响应时长一般比普通接口长你就要设计合理的超时策略客户端超时设多少、服务端超时设多少、网关超时设多少要一层层算清楚。再比如高并发场景下所有用户同时发对话请求后端模型 API 的 QPS 有限制你需要做限流令牌桶、固定窗口、漏桶这些算法课程里都提过只是当时没想过它们会用在“防止大模型 API 被打爆”上。还要注意部署环节。本地开发时直接uvicorn app:app就行但生产环境通常要套一层 Gunicorn、Nginx而 Gunicorn 的 worker 模式对 SSE 有影响——如果你用gunicorn -k sync会导致所有 SSE 连接阻塞要用-k gevent或者更好的用-k uvicorn.workers.UvicornWorker。这些知识点课程里不可能全部覆盖但基础理解到位了排查起来就有方向。3. 重新理解课堂上讲过的知识从“考纲”到“生产”3.1 HTTP基础与“流式响应”的真实差异我以前学 HTTP 总觉得抽象。状态码 200 和 201 有什么区别204 No Content 到底什么时候用304 Not Modified 跟缓存有什么关系直到做 AI 应用开发这些全变成了日常。举个例子SSE 的响应码连接建立后如果一切正常服务端返回 200如果用户的鉴权 token 过期返回 401如果模型服务商限流返回 429 Too Many Requests如果网关超时可能是 504。前端要根据不同状态码给出不同提示而不是一律弹“网络错误”。更关键的是SSE 本身也是 HTTP 响应体流式传输这意味着“响应已经开始了但还没结束”。这就带来一个传统开发里很少遇到的问题如果响应已经发送了一部分服务端才发现需要中断或报错该怎么办你是直接断开连接还是先发一段特殊格式的数据这直接影响到前端的体验和稳定性。我在写代码时养成了一个习惯服务端生成数据时每条消息要么是完整内容要么是明确的错误事件不搞模糊状态。3.2 前端异步、事件循环与CORS的现场教学AI 应用开发前端虽然是辅助但 A 端体验往往决定产品成败。流式渲染的本质是对异步事件的持续处理React 的useState配合流读取回调每拿到一块新文本就追加到消息列表。这里有几个前端基础问题会决定你是否翻车第一是事件循环理解不到位会导致“停止按钮失灵”。用户点击停止后abort()发出信号但处理和渲染事件仍然是异步的你可能会看到界面上又蹦出几条消息才停下来。这是正常的因为正在执行中的微任务和渲染队列不会被当场打断。第二是 CORS。前端和后端不在同一个域时SSE 接口要正确设置跨域头。要注意的是EventSource浏览器原生 SSE 客户端默认不支持自定义请求头如果你需要带鉴权 token通常不能用 EventSource得自己用fetchReadableStream来做。这算是一个很常见的“课上讲过但实际才理解”的点。第三是中文乱码。fetch 读取流式响应时返回的是二进制字节流。如果直接用TextDecoder逐块解码遇到一个中文拆到两个数据块时就可能出现乱码。解决办法是创建解码器时设置{ stream: true }让解码器自己处理分块缓冲const decoder new TextDecoder(utf-8, { stream: true });这个细节我第一次写的时候完全没概念网上搜了一堆“SSE 中文乱码”才找到答案。后来翻了课程笔记发现说明里其实有提到 “UTF-8 是多字节编码边界截断需要特殊处理”当时没在意现在算是交过学费了。3.3 数据结构与算法Token、窗口和限流大模型应用的很多问题追到根上还是数据结构的问题。token 计算就是一个典型的“字符串处理”问题。大模型并不是按“字”计费而是按 token 计费。一个中文词可能对应多个 token不同的 tokenizer 分配结果不一样。你需要了解 token 的概念才能在 prompt 设计时控制长度。我见过同事一次性把三万字文档全塞进 prompt直接把上下文窗口撑爆报错 “maximum context length exceeded”。消息管理也是。多轮对话其实就是一个“窗口”维护问题你要保留用户和 AI 最近 N 轮对话同时限制总 token 数量不能超过上下文上限。删掉哪些、保留哪些、什么时候总结压缩一轮历史这本质上是一个缓存淘汰策略——跟操作系统里学过的 LRU 很像。RAG 知识库更是如此。文档切分要考虑段落边界、语义完整性向量化后要支持近似最近邻搜索召回结果还要做重排序。数据库课里讲过的“索引”思想在这里仍然适用没有索引的检索是全表扫描有了向量索引才能支撑百万级文档毫秒级召回。我在做 RAG 时才真正理解为什么课程里反复强调“索引是查询性能的根本”。3.4 数据库与缓存向量检索并不神秘很多 AI 应用开发面试题里会问 RAG、向量数据库、embedding。第一次听到“向量检索”这个词我以为是全新的技术。后来真正上手发现原理并不陌生把一段文本转成一串数字一般是 768 维、1024 维或者更高两个文本的距离越近语义上越相关。然后就是找“与查询向量最近的那些向量”——这是最典型的 K 近邻问题。课程里学过的基本 SQL、索引、事务在 AI 应用里一个不少。用户表、会话表、消息表都是普通 MySQL会话状态和限流计数放 Redis只有在做知识库检索时才引入向量数据库。我之前遇到过一个性能问题知识库有 50 万条文档检索一次要 2 秒多用户一多直接把后端 CPU 打满。后来把 SQL 里的向量字段改成专用向量索引查询时间降到几十毫秒。这个过程让我意识到所谓 AI 技术栈并不是替换掉了传统技术而是传统技术之上叠加了新的索引和检索策略。4. 一次完整的对接实操从需求到上线全记录4.1 需求定义与方案设计这里记录一个典型的内部工具开发过程做一个“智能问答助手”要求支持多轮对话、流式输出、可停止、可计费。接口设计为POST /v1/chat请求体大致如下{ conversation_id: uuid, messages: [ { role: user, content: 请帮我总结这份合同的风险点 } ], stream: true }设计要点有三个。第一不直接传大模型厂商的原始 message 结构而是业务层自定义一个精简结构由适配层负责转换。这样以后换模型服务商业务代码不用动。第二conversation_id用于标识会话后端需要维护历史消息列表和 token 使用记录。第三所有接口默认支持流式和非流式两种模式stream: false时一次性返回完整结果方便测试和批量任务。4.2 服务端SSE接口实现我用 FastAPI 写了一个完整的 SSE 接口。核心代码如下import json from uuid import uuid4 from fastapi import FastAPI, Request, HTTPException from fastapi.responses import StreamingResponse app FastAPI() def generate_stream(prompt: str): # 这里是模拟大模型流式返回实际项目会调用真实模型 SDK for token in [你好, , 我是, AI, 助手, 。, 我可以帮你, 处理, 合同, 风险。]: yield fdata: {json.dumps({content: token}, ensure_asciiFalse)}\n\n yield data: [DONE]\n\n app.post(/v1/chat) async def chat(request: Request, body: dict): prompt body.get(messages, [])[-1].get(content) if not prompt: raise HTTPException(status_code400, detail缺少用户消息) async def event_stream(): try: for chunk in generate_stream(prompt): # 检查客户端是否断开 if await request.is_disconnected(): break yield chunk except Exception as e: yield fdata: {json.dumps({error: str(e)}, ensure_asciiFalse)}\n\n return StreamingResponse( event_stream(), media_typetext/event-stream, headers{ Cache-Control: no-cache, X-Accel-Buffering: no, Connection: keep-alive, } )这里面有两个关键点。第一是request.is_disconnected()它处理“用户点停止按钮后前端断开连接”的情况。如果不加这个检查生成器会一直跑完整个大模型响应浪费 token 和算力。第二是错误处理流式响应的错误不能靠状态码通知——因为响应可能已经开始发了所以要把错误包装成 SSE 事件推给前端由前端判断并展示。还有一个小技巧生产环境通常不能直接把body当 dict 用最好用 Pydantic 模型做请求体校验。我第一次写时图省事直接接收 dict结果线上出现各种字段缺失、类型错误后来老老实实加了模型类。4.3 前端Fetch流式读取与AbortController前端我用 React 写了一个接流式接口的组件。核心逻辑是点击发送后创建请求用 fetch 读取流式响应边读边解析 SSE 消息渲染到界面上同时维护一个AbortController用户点击“停止”时调用 abort。为了处理 SSE 的分帧问题需要先做部分消息切割。SSE 消息以两个换行符分隔但底层网络包可能会把一个完整事件拆到多个 chunk也可能把多个事件合并到一个 chunk。所以要走一个“缓冲拼接”的流程维护一个变量buffer每次收到新数据先追加进去然后按\n\n分割保留最后一个可能不完整的片段继续等待。建议最好不要用浏览器原生的EventSource做这个事因为多数业务场景需要带鉴权 header而 EventSource 不支持。用 fetch 示例代码如下import { useState, useRef } from react; function ChatWidget() { const [messages, setMessages] useState([]); const [streaming, setStreaming] useState(false); const abortRef useRef(null); async function send() { const controller new AbortController(); abortRef.current controller; setStreaming(true); const resp await fetch(/v1/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ conversation_id: test-001, messages: [{ role: user, content: 你好 }], stream: true }), signal: controller.signal }); if (!resp.ok) throw new Error(HTTP ${resp.status}); const reader resp.body.getReader(); const decoder new TextDecoder(utf-8, { stream: true }); let buffer ; try { while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const events buffer.split(\n\n); buffer events.pop(); for (const evt of events) { if (!evt.startsWith(data:)) continue; const data evt.slice(5).trim(); if (data [DONE]) { setStreaming(false); return; } const json JSON.parse(data); if (json.error) { console.error(json.error); setStreaming(false); return; } setMessages(prev { const last prev[prev.length - 1] || { role: assistant, content: }; const rest prev.slice(0, -1); return [...rest, { ...last, content: last.content (json.content || ) }]; }); } } } catch (err) { if (err.name AbortError) { setMessages(prev [...prev, { role: assistant, content: 已停止生成 }]); } else { console.error(err); } } finally { setStreaming(false); } } function stop() { if (abortRef.current) abortRef.current.abort(); } return ( div button onClick{send} disabled{streaming}发送/button button onClick{stop} disabled{!streaming}停止/button {messages.map((m, i) ( div key{i}{m.content}/div ))} /div ); }这里面有个我自己踩过的坑catch里判断AbortError时不能只靠err.name因为代码里其他位置也可能抛同名错误。更稳妥的做法是判断controller.signal.aborted是否为 trueif (err.name AbortError controller.signal.aborted) { // 确实是用户主动停止 }另一个坑是用户点停止后如果直接把整个组件状态清空可能会导致已经展开的消息列表重新渲染丢内容。我最后采用的方式是追加一条系统提示“已停止生成”而不是把当前内容清空。4.4 落地中踩过的坑与排查技巧整理三个月的实操里我整理了一份高频问题速查表贴在这里现象可能原因排查与解决SSE 接口连不上Nginx 缓冲、代理层缓冲设置X-Accel-Buffering: no或加proxy_buffering off;中文乱码TextDecoder 没设置 stream 模式new TextDecoder(utf-8, { stream: true })响应卡住最后一条模型端没有发送[DONE]前端加超时兜底超过 N 秒自动停止并提示停止按钮失效后端没检测连接断开生成循环里加request.is_disconnected()检查token 费用过高中断后模型仍继续生成前后端联动前端 abort 后端任务取消429 限流模型 API 超过 QPS 限制加本地令牌桶限流 队列重试CORS 报错跨域配置缺失后端加allow-origins配置注意预检请求流式内容重复前端累计了重复 buffer检查 buffer 分割逻辑pop()后再拼回未完成片段“排查技巧”这个词我多说一句线上问题不要盲目重启。先看日志、看监控指标、看最近变更。有一次线上流式响应非常慢我排了半天接口代码最后发现是网络链路上多了一层代理代理默认超时时间太短导致连接被中途掐断。这种问题靠“读代码”是看不出结果的一定要结合链路追踪。5. 三个月总结给想转岗的人一些可落地的建议5.1 学习路线怎么排按优先级而不是按目录很多人在网上收藏“AI 应用开发学习路线”但收藏完就吃灰了。我的建议是不要按网上那份“从入门到精通”的章节顺序学要按工作里实际用到的技术和频率排序。第一优先级是编程基础和 HTTP至少熟悉 Python 或 TypeScript能读懂状态码、请求头、响应体。第二优先级是模型 API 的用法会调 OpenAI 风格接口理解 messages、temperature、max_tokens 参数。第三优先级是流式处理和中断控制SSE、fetch ReadableStream、AbortController。第四优先级是 RAG 和 Agent会做文档切分、向量检索、工具调用封装。第五优先级是部署和稳定性Docker、Nginx、Gunicorn、日志、监控、限流。我不建议一开始就学 LangChain 这类框架。不是说它没用而是如果你不懂底层逻辑框架出错时你会束手无策。先用原生代码写一个小问答接口再手动做一个流式渲染再自己写一个工具调用 demo最后才是引入框架提升效率。这个顺序不能跳。5.2 AI应用开发面试与简历哪些问题被反复问面试题这块可能是大家最关心的。我梳理了一下自己被问过和观察到的高频问题分类如下基础类SSE 和 WebSocket 的区别如何实现打字机效果fetch 如何流式读取响应AbortController 如何取消请求模型类如何设计一个多模型适配层不同模型的参数如何归一化如何处理 token 超限如何控制 prompt 长度工程类如何实现流式接口的超时兜底如何做限流如果大模型响应慢怎么避免用户等待焦虑Agent 类tool calling 的原理如何注册一个工具RAG 的完整链路是什么简历上写项目时别只写“调用大模型 API 实现智能对话”。我现在的写法是“基于 SSE 实现大模型回答流式渲染支持中途终止生成通过前后端联动中断控制实现 token 成本优化设计多模型适配层屏蔽不同厂商接口差异基于向量检索实现知识库问答检索耗时从秒级优化到百毫秒级。”这样写面试官至少知道你不只是伸手调包。有一次面试官问“如果模型限流了你怎么让用户感知到超时和重试”我聊了聊服务端用队列削峰、前端给“是否重试”按钮。这类问题没有标准答案但能看出你是否真的在真实的流量环境里干过活。5.3 心态转变从“写功能”到“提质量”最后一个想说的是心态。转岗前我写代码的思维是“功能能跑就行”接口通了页面能点就交付了。现在我的思维变成了“功能能跑只是第一步线上能不能扛住、出错了会不会拖垮整个系统、有没有日志能快速定位问题”才是更重要的标准。AI 应用开发的特殊性在于大模型是外部依赖它可能超时、限流、返回垃圾内容、甚至崩溃。你的职责不是“保证模型不犯错”而是“保证模型犯错时用户的损失可控”。所以代码里到处都是超时设置、重试策略、熔断降级、缓存兜底、异常捕获。这些内容在课程里可能只是一个知识点但在生产环境里是“事故和差评的分界线”。我在这三个月里犯过很多错也写了无数个try-catch。但回头看真正让我成长的恰恰是最早觉得“没用”的那些基础课程。数据结构帮我理解了向量检索HTTP 帮我理解了 SSE 和 Nginx 缓冲数据库知识帮我设计了会话存储和消息索引。所谓 AI 应用开发说到底还是“应用开发”只是多了一个会输出概率的依赖罢了。最后再分享一个小技巧建议想转岗的人把旧笔记翻出来对照现在的工作给每个知识点标注“实际场景”。比如 HTTP 状态码 429 旁边写上“大模型限流”AbortController 旁边写上“停止生成按钮”TextDecoder 旁边写上“SSE中文分帧”。这张新旧对照表比任何网上的学习路线都更适合自己因为它记录的是你亲手踩过的坑和现实问题的答案。