跨切面流程:客户端工具、审批、续跑、持久化与多端生成态
30 秒导读: 前几章讲了
ChatClient怎么把一次请求变成流、怎么把 chunk 拼成消息。这一章讲流之上、跨越多次请求的"业务级"编排:模型点名要跑一个浏览器里的工具,谁去执行、结果怎么送回?工具需要人点"同意",怎么等?一个工具跑完了,要不要自动再问一轮模型(agent 续跑)?消息怎么落盘、且在"边流边被清空"时不把已清掉的对话又写回来?以及——同一个会话在别的标签页/设备上正在生成,本端 UI 怎么知道?这四条线都散落在ChatClient里,本章把它们集中讲透。
本章聚焦编排与时序,不重复第 2 章(传输与线协议)的 chunk 怎么来、也不重复第 3 章(chunk → parts 引擎)的 chunk 怎么解析。这里只回答一个问题:当 chunk 已经进来、parts 已经拼好,ChatClient 还要额外做哪些"人和 agent 层面"的决策,以及这些决策为什么这么难。
主要源文件只有两个:
| 文件 | 职责 |
|---|---|
packages/ai-client/src/chat-client.ts | 四条线的编排中枢:工具执行回调、审批转发、续跑循环、生成态 |
packages/ai-client/src/client-persistor.ts | 持久化写队列 + "边流边清"的迟到 chunk 抑制 |
1. 先建立直觉:四条线都在解决同一类问题
这一节先不进代码。先说清楚这四条线是什么、以及它们共同的暗线。
一次普通的聊天很简单:用户发消息 → 模型流式回文字 → 结束。但真实的 agent 应用里,模型不只会"说话",它还会:
- 让浏览器帮它干活——比如"查一下这个用户的待办列表",而这个查询只有客户端(浏览器)能跑(有 cookie、有本地状态)。
- 请求许可——比如"我要删这条记录,你(人)同意吗?"
- 多轮自我推进——工具跑完把结果喂回去,模型接着基于结果再想、再调工具,直到它说"我说完了"。
这三件事,加上"把这一切存下来、还要在多个标签页/设备间保持一致",就是本章的四条线。
它们共同的暗线是时序错位:
模型说"跑工具 X" 工具异步执行(可能几秒)
│ │
▼ ▼
run A 还在流 ────────── run A 结束了 ────── 工具结果这才回来
(它该算谁的?)
四条线的每一个难点,归根结底都是**"某件事发生的时刻,和它该归属的上下文,对不上"**:
| 线 | 时序错位的具体表现 | 本章对应节 |
|---|---|---|
| 客户端工具 | 工具结果迟到,原来的 run 已经结束 | §3 |
| 工具审 批 | 人点"同意"发生在流早已结束之后 | §4 |
| agent 续跑 | 工具刚落地,要不要"自动再发一轮"、且别重复发 | §5 |
| 持久化 / 多端 | 陈旧的写覆盖新状态;清空后迟到的 chunk 把对话写回来;别的设备在生成 | §6 |
记住这条暗线,后面每个"看起来很绕"的标志位(runEventContext、continuationPending、generation、clearedRunIds)就都有了动机——它们全是为了把"迟到的事"钉回"正确的上下文"。
2. 顶层全景:一次带工具的对话,四条线怎么串起来
先看一张"从发消息到落盘"的高层图,标出四条线各自在哪个环节切入。怎么读:从上到下是时间,│ 是主干流程,← 标出的是某条线在此处插入。
用户 sendMessage
│
▼
streamResponse() ── 起一个新 run,记 generation ─────────────┐
│ │
▼ │
connection.send → 订阅循环收 chunk → processor 拼 parts │
│ │
├─ chunk = TOOL_CALL(客户端工具) │
│ └→ processor 回调 onToolCall ──────────← 线①客户端工具
│ └ 异步 execute,登记 pendingToolExecutions │
│ │
├─ chunk = 需要审批的 TOOL_CALL │
│ └→ processor 回调 onApprovalRequest ───← 线②审批 │
│ └ 发 events.approvalRequested,等人回应 │
│ │
▼ │
RUN_FINISHED → onStreamEnd → resolveProcessing() │
│ │
▼ (finally 块) │
await 所有 pendingToolExecutions ──────────────← 线①收尾 │
drainPostStreamActions() ───────────────────← 线②/③ 排队动作 │
lastPart 是 tool-result 且 finishReason≠stop? │
└→ checkForContinuation() → 再跑 streamResponse ← 线③续跑 ─┘
│
▼
每次 messages 变 → persistor 写队列(带 generation 丢弃陈旧)← 线④持久化
RUN_STARTED/FINISHED/ERROR → sessionGenerating ────────← 线④多端生成态
部件一句话职责:
| 部件 | 干什么 | 位置 |
|---|---|---|
onToolCall 回调 | 模型点名客户端工具时,自动查表、异步执行、登记待办 | chat-client.ts:343-403 |
pendingToolExecutions | 追踪"还没跑完的客户端工具",流收尾前要 await 它们 | chat-client.ts:132、991-993 |
onApprovalRequest 回调 | 把"需要人同意"转成 approvalRequested 事件 | chat-client.ts:404-424 |
addToolApprovalResponse | 人回应后,按 approval.id 找到 toolCallId 并喂给 processor | chat-client.ts:1260-1298 |
checkForContinuation / shouldAutoSend | 工具都齐了就自动再发一轮,且去重防重复 | chat-client.ts:1326-1370 |
postStreamActions 队列 | 流进行中到来的续跑请求,排到流结束后再执行 | chat-client.ts:1303-1321 |
ChatPersistor | 有序写队列 + 陈旧写丢弃 + 边流边清抑制 | client-persistor.ts 全文 |
sessionGenerating | 由 run 生命周期驱动的"共享生成态",反映跨端活动 | chat-client.ts:461-490、520-525 |
下面逐条线深入。
3. 线①:客户端工具的自动执行
3.1 它要解决的小问题
模型在流里发出一个 tool-call,比如 getTodos({userId})。有些工具的实现活在浏览器(需要本地 fetch、cookie、DOM),服务端跑不了——这就是"客户端工具"。ChatClient 的任务:发现这是个我认得的客户端工具 → 自动跑它 → 把结果送回流,让对话能继续。 用户不用写一行胶水代码。
3.2 思路:查表 → 异步执行 → 登记待办 → 迟到也归位
关键设计有三层,依次递进:
第一层,查表。 processor 拼出一个 tool-call 时会回调 onToolCall。回调先在两张表里找这个工具名:优先用本次流快照 activeClientTools,回退到当前注册表 clientToolsRef.current。
真实实现:
// chat-client.ts:349-353,onToolCall 回调开头
const clientTools =
this.activeClientTools ?? this.clientToolsRef.current
const clientTool = clientTools.get(args.toolName)
const executeFunc = clientTool?.execute
if (executeFunc) { … }
为什么要两张表?activeClientTools 是 streamResponse 开跑时对注册表拍的快照(chat-client.ts:878 new Map(this.clientToolsRef.current),915 赋给 activeClientTools)。这样即便流进行中 updateOptions({tools}) 换了工具表,本次流仍用它开始时的那套工具,不会中途串味。找不到 execute 的(纯服务端工具)就跳过——那是服务端的活。
第二层,异步执行 + 登记待办。 找到 execute 就立刻发起异步执行,并把这个未决的 Promise 塞进 pendingToolExecutions(以 toolCallId 为键):
// chat-client.ts:361-401(节选)
const executionPromise = (async () => {
try {
const context =
this.activeClientTools === null ? this.context : this.activeContext
const output = await executeFunc(args.input, {
toolCallId: args.toolCallId,
context: context as TContext,
emitCustomEvent: () => {},
})
await this.addToolResultForClientTool(
{ toolCallId, tool: args.toolName, output, state: 'output-available' },
clientTool, runEventContext,
)
} catch (error) {
await this.addToolResultForClientTool(
{ …, output: null, state: 'output-error', errorText: error.message },
clientTool, runEventContext,
)
} finally {
this.pendingToolExecutions.delete(args.toolCallId) // 跑完从待办移除
}
})()
this.pendingToolExecutions.set(args.toolCallId, executionPromise)
这张待办表的用处在流收尾时兑现:streamResponse 在成功路径上,finalizeStream 之前会等所有待办跑完——
// chat-client.ts:991-993
if (this.pendingToolExecutions.size > 0) {
await Promise.all(this.pendingToolExecutions.values())
}
这保证"工具还在跑"时不会过早把流当作彻底结束。(每次新流开始会 pendingToolExecutions.clear(),见 chat-client.ts:870。)
第三层,迟到结果归位——runEventContext。 这是本线最精妙的一处。工具是异步的,它的结果可能在原来的 run 早已 RUN_FINISHED 之后才回来。此时若按"当前 run"上报,结果就会挂错 run。解决办法:在执行开始的那一刻就把当前 run 的上下文捕获下来,随结果一起回传。
// chat-client.ts:358-359,execute 发起前
const runEventContext =
this.devtoolsBridge.getCurrentRunEventContext()
这个 runEventContext(类型 ChatClientRunEventContext,含 threadId/runId/可选 toolCallId,见 events.ts:8-12)一路传进 addToolResultForClientTool,最终作为 context 附到 tools:result:added 事件上(chat-client.ts:1218-1224)。一句话:结果无论多晚回来,都报在它当初所属的 run 名下,而不是"回来那一刻恰好是哪个 run"。