KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 大模型流式输出:不只是 token 流 — keel 龙骨
这一章回答:把模型的流式输出做成产品体验时,事件该怎么设计、前端怎么增量渲染、取消怎么传递。
这一章回答:把模型的流式输出做成产品体验时,事件该怎么设计、前端怎么增量渲染、取消怎么传递。
SSE 在这轮 AI 应用里最主要的用途就是流式输出。但真实产品要传的不只是 token——还有思考过程、工具调用、引用来源、错误与终止原因。事件设计决定了前端能做到什么、以及出错时能不能说清楚。
一、事件类型设计
最小可用的一组事件:
| 事件 | 时机 | 载荷要点 |
|---|---|---|
run_started |
运行开始 | run_id、模型、预计阶段 |
delta |
每个文本片段 | index(序号)、text(片段) |
tool_call |
模型调用工具 | 工具名、参数(可脱敏)、call_id |
tool_result |
工具返回 | call_id、结果摘要、是否出错 |
citation |
引用来源 | 来源 id、标题、片段 |
error |
出错 | 错误码、可展示文案、是否可重试 |
done |
正常结束 | reason(stop/length/…)、用量统计 |
cancelled |
被取消 | 已完成的部分、取消来源 |
# 服务端:一次工具调用的事件序列
yield sse("run_started", {"run_id": rid, "model": "xxx"})
yield sse("tool_call", {"call_id": "c1", "name": "search", "args": {...}})
yield sse("tool_result", {"call_id": "c1", "ok": True, "summary": "命中 3 条"})
yield sse("delta", {"index": 0, "text": "根据检索结果,"})
yield sse("delta", {"index": 1, "text": "答案是 42。"})
yield sse("citation", {"source_id": "s1", "title": "…", "snippet": "…"})
yield sse("done", {"reason": "stop", "usage": {"in": 120, "out": 64}})
设计原则:
- 每个事件自包含(完整 JSON),不依赖前后文拼接;
- 带序号(
index/seq),让客户端能去重、能排序、能发现丢失; - 状态类事件传完整状态而非增量(幂等友好,呼应第 04 章);
- 错误事件要可展示:给用户看的文案与给日志用的错误码分开。
二、token 流的三个真实问题
问题 1:逐 token 渲染会让 Markdown 反复错乱
用户看到的是:# → # → # 标 → …… 期间 Markdown 渲染会不停闪烁(未闭合的代码块尤其严重)。
三种解法(按推荐度):
| 解法 | 做法 | 适用 |
|---|---|---|
| 节流渲染 | 攒够 N 个 token 或每 50~100ms 渲染一次 | 通用,最省事 |
| 按块发送 | 服务端按句子/段落切片,而不是逐 token | 需要服务端有切分逻辑 |
| 末尾保护 | 未闭合的 ``` / ` | ` 等先不渲染,等闭合 |
// 节流渲染示例
let buf = '', timer = null;
function onDelta(text) {
buf += text;
if (!timer) timer = setTimeout(() => { render(buf); timer = null; }, 60);
}
问题 2:思考过程(reasoning)要不要发
发的好处是体验透明(用户知道在干活),代价是内容泄露风险 + 前端要区分渲染。
建议:
- 内部工具/调试模式发;
- 对客产品用"阶段提示"代替(
tool_call事件本身就是很好的进度提示:"正在搜索…"),而不是把完整思维链发出去; - 如果发,用独立事件类型(
reasoning),前端折叠展示。
问题 3:用量与计费信息
done 事件里带上 token 用量,前端展示、后端计费都用它。不要把用量放在 delta 里(会重复计算)。
三、取消:要能一路传到模型调用
用户在前端点"停止",理想的结果是:模型停止生成、工具不再执行、账单停止累积。
① 前端:fetch/EventSource 关闭 + 调 POST /runs/{id}/cancel
② API:写 cancel 标记(持久化,不是内存变量)
③ Worker:在每个可中断点检查标记(每步工具调用之间、每个 token 批次之间)
④ 模型调用:关闭底层 HTTP 连接(aiohttp/httpx 的 cancel 会中断流式读取)
⑤ 落库:写 cancelled 事件 + 已生成的部分内容
⑥ SSE:客户端(含重连的)收到 cancelled,UI 停止
# 关键:取消必须是「协作式」的,不能靠 kill
async for chunk in llm.astream(prompt):
if await is_cancelled(run_id):
await save_partial(run_id, buffer)
await emit(run_id, "cancelled", {...})
break
buffer += chunk
await emit(run_id, "delta", {...})
⚠️ 只关闭 SSE 连接不会停止模型。这是最常见的误解:用户点了停止,前端停止渲染,但后端还在烧钱生成。必须走独立的取消 API + 协作式检查。
四、前端:增量渲染与错误兜底
const es = new EventSource(`/api/runs/${rid}/stream`);
let lastSeq = -1;
es.addEventListener('delta', (e) => {
const { index, text } = JSON.parse(e.data);
if (index <= lastSeq) return; // 重连导致的重复
lastSeq = index;
appendThrottled(text);
});
es.addEventListener('tool_call', (e) => showProgress(JSON.parse(e.data).name));
es.addEventListener('error', (e) => showError(JSON.parse(e.data))); // 业务错误
es.addEventListener('done', () => es.close());
es.onerror = (e) => {
if (es.readyState === EventSource.CLOSED) showFatal('连接已终止');
// readyState CONNECTING 表示正在自动重连,不需要特殊处理
};
三条兜底:
- 超时兜底:超过 N 秒没有收到任何事件(含心跳),主动提示"可能已断开"并尝试重连;
- 重连次数上限:浏览器会无限重连,业务上应该限制(超过 M 次转为全量拉取);
- 最终一致性:流结束后用一次
GET /runs/{id}校验最终内容,避免"流里丢了事件但 UI 不知道"。
第 3 条在实际产品里很重要:把 SSE 当作"加速感知",把全量接口当作"事实"。UI 可以先按流渲染,最后用全量结果覆盖一次。
五、与 Agent 中间过程的关系
Agent 的流式输出比单轮对话复杂,因为中间有多阶段:
[思考] → [工具调用1] → [工具结果1] → [思考] → [工具调用2] → [结果] → [正文]
把这整个过程压成"一串 token"是常见错误——用户看不到在做什么,出问题也无法定位。正确做法是用不同事件类型表达阶段:
// 前端按阶段渲染:折叠的工具调用 + 展开的最终正文
tool_call → 折叠卡片:正在调用 search(…)
tool_result → 折叠卡片显示结果摘要
delta → 正文区域增量追加
citation → 正文下方的引用列表
这也与 Harness 源码解剖室板块里讲「多宿主托管」的那门课第 03 章的"事实日志"思路一致:结构化事件优于把状态压成文本流。
六、跨模型的一致性
不同厂商的流式协议不同(OpenAI 的 chat.completion.chunk、Anthropic 的 content_block_delta 等)。应用层应该定义自己的统一事件格式,在 provider adapter 里做转换:
Provider A chunk ┐
Provider B chunk ┼→ 统一事件(delta/tool_call/done)→ SSE → 前端
Provider C chunk ┘
好处:换模型不改前端,换协议不改产品逻辑。(Provider 适配的完整链路在会员专区的项目架构考古课第 10 章有端到端记录。)
动手:可观察结果
| 产出 | 判断标准 |
|---|---|
| 一套事件定义 | 覆盖 run_started / delta / tool_call / tool_result / citation / error / done / cancelled |
| 一个增量渲染前端 | 逐 token 不闪烁(节流或按块),代码块未闭合时不渲染错乱 |
| 取消链路验证 | 点停止后:模型停止生成、用量不再增长、cancelled 事件到达 |
| 重连后的幂等渲染 | 人为重复投递 delta,文本不重复 |
| 统一事件格式 | 换一个模型供应商,前端代码零改动 |
完成标志:一次完整的 Agent 运行,用户能看到"在做什么 → 结果 → 正文 → 引用",中途可取消,断网 10 秒后能续上且不错乱。
故障注入
| 注入方式 | 观察 |
|---|---|
| 逐 token 发送含代码块的文本 | 未做节流/保护时 Markdown 是否闪烁错乱 |
| 只关闭 SSE 不调取消 API | 后端是否继续生成(用量是否继续累积) |
| 取消标记写在进程内存 | 多实例下取消是否失效 |
| 流结束后不校验全量 | 丢事件时 UI 是否显示不完整内容而不自知 |
| 把整个推理过程当 delta 发 | 前端是否能区分阶段(体验与信息泄露问题) |
| 换一个模型供应商 | 前端是否需要改动(验证统一事件格式是否成立) |
自测题
- 一套可用的流式事件至少包含哪些类型?
done里应该带什么? - 逐 token 渲染会造成什么问题?给出两种解法。
- 为什么"关闭 SSE 连接"不等于"取消任务"?正确的取消链路是什么?
- 为什么建议"流结束后再校验一次全量"?
- 换模型供应商时,怎么做到前端零改动?