记一次把两个 Agent 页面的流式展示统一了,顺带挖出一个隐形 bug

2026年07月08日1 次阅读0 人喜欢
大模型AgentSSELangGraph重构小破站建设

起因:就想着把两个页面对齐一下

站里有两个用 LangGraph ReAct Agent 驱动的对话页面:

  • /chat 知识问答机器人,RAG 检索作为工具按需调用,带会话持久化
  • /create 创作助手,挂在草稿编辑器右边的 Drawer 里,能改标题、写文案、回填草稿表单

本来这俩各写各的,前端各一套渲染、后端各一套 SSE 协议。前阵子我让一个模型帮忙把两个页面的展示方式封装一下、对齐成一套,结果它改了一半改不下去了,甩给我一个半成品。

我接手一看,两个问题:

  1. /chat 页面的工具调用,死活不显示
  2. /create 那边根本没按统一方式改,还是老样子

我以为是个小修小补,结果一路挖下去,挖出来一个隐形的 P0 bug,还有一个根因级别的架构问题。这篇就记一下这个过程。

问题一:工具调用去哪了

先看 /chat。现象很明确:发一条会触发工具的消息,思考过程有,最终答案有,唯独中间那步「调了什么工具、返回了什么」凭空消失

我先去抓了一份真实的 SSE 流,长这样(截取关键部分):

复制代码
event: think_start
event: think_chunk   {"content":"...需要使用 search_posts_meta 工具..."}
event: think_end
event: tool_start    {"tool":"search_posts_meta","args":{...},"step":1}
event: tool_end      {"tool":"search_posts_meta","result":"找到 178 篇...","step":1}
event: think_start
event: think_chunk   {"content":"...我已经检索到了最新的一篇文章..."}
event: think_end
event: content_chunk {"content":"就在今天..."}
event: done

后端明明发了 tool_start / tool_end。前端 hook 也明明收到了,存进了 msg.tools。组件也明明把 tools 传给了渲染组件。那为啥不显示?

翻渲染组件,找到了这么一段「精妙」的逻辑:

ts 复制代码
// 当 reactTimeline 为空但有 tools 时,从 tools 构建 fallback timeline
const toolsTimeline = useMemo(() => {
  if (orderedTimeline.length > 0 || tools.length === 0) return [];
  // ...把 tools 拼成 timeline
}, [orderedTimeline.length, tools]);

const effectiveTimeline = orderedTimeline.length > 0 ? orderedTimeline : toolsTimeline;

意思就是:只有当思考(reactTimeline)完全为空时,才用 tools 兜底渲染;一旦有了思考,tools 直接被忽略。

可现实是,DeepSeek 这种带推理的模型,工具调用前后都有一大段思考。于是思考一来,orderedTimeline 不为空,工具调用的 fallback 就被整个跳过了——工具被思考「盖住」了。

这就是看不到工具调用的原因。不是数据没到,是渲染逻辑把它丢了。

翻 git 历史找答案

既然是「统一展示」搞出来的,那原版 /chat 是怎么渲染工具的?

git log 翻到引入 reactTimeline 的那次重构(c9ca615),看原版怎么处理 tool_start / tool_end,一下就看明白了。原版的关键设计是:

工具调用和思考,是合并在同一个 reactTimeline 里的,按 SSE 的真实时间顺序穿插。

思考是一段 think,工具调用是一轮 loop(里面装着 action / observation),最终答案是正文。渲染出来就是一条完整的时间线:

复制代码
[思考折叠块] → [检索轮次 #1(带工具调用和结果)] → [再思考] → [正文]

而那个半成品把工具单独塞进了一个 tools 数组,跟 reactTimeline 分了家,再加上上面那个「有思考就不看 tools」的 fallback,工具自然就没了。

根因不是后端,是前端的数据建模走偏了。

修法:把工具融回时间线

思路很简单——让前端 hook 回到原版的姿势:收到 tool_start / tool_end 时,不要单独存 tools,而是按时间顺序融进 reactTimeline 的 loop 条目。

ts 复制代码
case "tool_start": {
  const { tool, args, step, startedAt } = data;
  // 先把上一个进行中的 think 标记完成
  const reactTimeline = msg.reactTimeline.map((item) =>
    item.type === "think" ? { ...item, isStreaming: false } : item,
  );
  // 找到或新建对应 step 的 loop,推入一个 action 步骤
  // ...
  reactTimeline.push({
    type: "loop",
    index: step,
    steps: [{ type: "action", content: argsContent, toolName: tool, startedAt, isStreaming: true }],
  });
  return { ...msg, reactTimeline };
}

case "tool_end": {
  const { result, step, endedAt, durationMs } = data;
  // 把该 loop 里最后一个 action 步骤转成 observation
  // ...
}

顺手还修了 think_chunk 里一处原地 mutate 的状态 bug——原来的代码直接 last.content += content,React 状态更新这么写是有引用问题的,改成返回新对象。

渲染组件那边,把那个吞掉工具的 fallback 删了,直接老老实实按 reactTimeline 渲染。工具调用回来了。

顺藤摸瓜:是不是该有个共享层

修到这我停下来想:为啥会出这种 bug?

因为两个 agent 各写各的,没有共享层/chat 一套 SSE 事件名、一套字段;/create 又一套。前端为了兼容两套,就写了各种 case "token": case "content_chunk": 的分支、各种 fallback。fallback 一多,早晚出事。

我让 explore agent 把两条链路从前后端都摸了一遍,结论是:

两个 agent 的骨架其实已经 70-80% 一样了——都用 createReactAgent、都用 streamEvents、都用同一套 SSE 编解码。差异集中在「锦上添花」层,不在「地基」层。

而且摸出来一个更吓人的东西。

真凶:一个隐形的 P0 bug

调研的时候,agent 甩给我一行:

后端 create-agent 发的事件叫 draft_patch,前端 useAgentStream 监听的是 patch。事件名对不上,patch 永远收不到

我当时第一反应是不信,亲自去对了一遍代码:

ts 复制代码
// create-agent.ts —— 发送
const emitPatch = (patch: DraftPatch) => {
  enqueue('draft_patch', patch);   // ← 发的是 draft_patch
};

// useAgentStream.ts —— 接收
case "patch": {                    // ← 监听的是 patch
  onPatch?.(data);
}

注释里还煞有介事写着「draft_patch → patch 事件映射」,实际上根本没映射。

这意味着什么?意味着 /create 助手那个「改标题、写文案、回填表单」的核心功能,从头到尾就没真正工作过。模型辛辛苦苦调了 emit_draft_patch 工具,patch 帧发出去了,前端压根不认这个事件名,表单纹丝不动。

一个事件名,一行的事,藏了不知道多久。

既然要修,不如把地基打好

问题出在「没有共享层」,那就别再头疼医头了。这次干脆把两个 agent 共有的流式逻辑抽出来,沉到一个共享模块里。

新建了 src/services/ai/agent-stream/

  • chunk-parsers.ts —— 从 chunk 里提取 reasoning(思考)、content(正文)、observation(工具结果摘要),纯函数,不依赖 LangChain 类型
  • sse-emitter.ts —— 一个 pumpAgentEvents,吃 streamEvents 事件流,吐标准 SSE 帧,处理思考/正文/工具的切换,工具还带上了 startedAt / endedAt / durationMs
ts 复制代码
export async function pumpAgentEvents(
  eventStream: AsyncIterable<StreamEvent>,
  emitter: SseEmitter,
  options: PumpAgentEventsOptions,
): Promise<void> {
  let inThink = false;
  // ...
  for await (const event of eventStream) {
    if (event.event === 'on_chat_model_stream') {
      // 分离 reasoning(think 帧)和 content(content_chunk 帧)
    }
    if (event.event === 'on_tool_start') { /* ... */ }
    if (event.event === 'on_tool_end')   { /* ... */ }
  }
}

然后两个 agent 都改成调它。chat-agent 从 337 行瘦到 90 行,create-agent 也清爽了。事件名在共享层里就统一约定好了,以后不会再出现 draft_patch 对不上 patch 这种事

bug 修了,顺手还白捡两个能力

共享层一抽,/create 立刻免费获得了两个之前没有的东西:

一是思考展示。 原来 create-agent 压根没做 reasoning 提取,模型如果开了思考模式,内容直接被丢掉。现在走共享层的 extractReasoningContent,思考过程能正常展示了。

二是工具耗时。 原来工具事件不带时间字段,时间线上看不出这步花了多久。现在 tool_end 自带 durationMs,渲染的时候能显示「调用 → 结果(1.2s)」。

复制代码
检索 #1
  ? 调用 search_posts_meta · {"sort_by":"date",...}
  ? 结果 找到 178 篇相关文章:1. 给小破站加了个... (1.2s)

前端也顺手对齐

既然在收拾,前端两个页面的体验差异也一起抹平了。把 /chat 里的两个小逻辑抽成通用 hook:

  • useExternalLinks —— MutationObserver 监听消息容器,Markdown 里的链接自动在新标签页打开。/create 之前没这个,点个链接就在编辑器里跳走了,草稿可能丢
  • useSmartAutoScroll —— 智能滚动,用户停在底部时才自动跟随,上翻看历史时不强制拉回来。/create 之前是粗暴地每次都拉到底

两个页面都接上,行为一致了。

几点反思

折腾完这一圈,有几个感触:

「统一」不能只统一表面。 那个半成品统一了前端组件、统一了类型定义,但后端两个 agent 的 SSE 协议还是各写各的。表面看着齐了,底下事件名对不上,于是有了那个隐形 P0。真正的统一得沉到协议层。

隐形 bug 最怕的是「功能看着能用」。 draft_patch 那个,因为模型偶尔会用别的方式表述、或者用户自己手动改了表单,可能一直没人发现「回填其实坏了」。要不是这次抓 SSE 对代码,可能还藏着。

共享层这种事,早抽早享受。 两个 agent 重复的代码(truncate、chunk 解析、SSE 编码)本来就在那摆着,一直没抽是因为「能跑」。等到要改的时候,改一处忘另一处,bug 就来了。这次抽完,chat 和 create 行为天然一致,改一处两边都生效。

没动的也明确了:/create 还是内存级、不持久化会话,这是我故意的——它挂在一个草稿编辑场景下,不需要聊天历史。不是所有能力都要搬,得看场景。


改完 typechecklint 都干净了,浏览器里也实跑验过了:/chat 的工具调用正常显示在思考之间、/create 改标题真的回填进表单了(P0 确认修复)、用带思考的模型时 create 那边的思考块也出来了。三件事都对得上。

如果你也在搞多 Agent 的项目,我的建议就一句:先把流式协议这一层钉死、共享出去,再往上面叠业务。 地基稳了,上面怎么折腾都踏实。

加载评论中...