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}})

设计原则:

  1. 每个事件自包含(完整 JSON),不依赖前后文拼接;
  2. 带序号(index / seq),让客户端能去重、能排序、能发现丢失;
  3. 状态类事件传完整状态而非增量(幂等友好,呼应第 04 章);
  4. 错误事件要可展示:给用户看的文案与给日志用的错误码分开。

二、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)要不要发

发的好处是体验透明(用户知道在干活),代价是内容泄露风险 + 前端要区分渲染。

建议:

问题 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 表示正在自动重连,不需要特殊处理
};

三条兜底:

  1. 超时兜底:超过 N 秒没有收到任何事件(含心跳),主动提示"可能已断开"并尝试重连;
  2. 重连次数上限:浏览器会无限重连,业务上应该限制(超过 M 次转为全量拉取);
  3. 最终一致性:流结束后用一次 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 发 前端是否能区分阶段(体验与信息泄露问题)
换一个模型供应商 前端是否需要改动(验证统一事件格式是否成立)

自测题

  1. 一套可用的流式事件至少包含哪些类型?done 里应该带什么?
  2. 逐 token 渲染会造成什么问题?给出两种解法。
  3. 为什么"关闭 SSE 连接"不等于"取消任务"?正确的取消链路是什么?
  4. 为什么建议"流结束后再校验一次全量"?
  5. 换模型供应商时,怎么做到前端零改动?

进入 keel 阅读