跳到主要内容

跨切面流程:客户端工具、审批、续跑、持久化与多端生成态

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

记住这条暗线,后面每个"看起来很绕"的标志位(runEventContextcontinuationPendinggenerationclearedRunIds)就都有了动机——它们全是为了把"迟到的事"钉回"正确的上下文"。


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:132991-993
onApprovalRequest 回调把"需要人同意"转成 approvalRequested 事件chat-client.ts:404-424
addToolApprovalResponse人回应后,按 approval.id 找到 toolCallId 并喂给 processorchat-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-490520-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) { … }

为什么要两张表?activeClientToolsstreamResponse 开跑时对注册表拍的快照(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"。

3.3 结果落地:addToolResultForClientTool 与 outputSchema 校验

不管是自动执行的结果,还是用户手动调 addToolResult(chat-client.ts:1192-1195)送来的结果,都汇入同一个私有方法 addToolResultForClientTool(chat-client.ts:1197-1244)。它做三件事:

① 按 outputSchema 校验输出。 只对非 error 结果校验;校验失败则把结果降级为 output-error:

// chat-client.ts:1202-1216(节选)
if (clientTool && result.state !== 'output-error') {
try {
result = { ...result,
output: this.validateClientToolOutput(clientTool, result.output) }
} catch (error) {
result = { ...result, output: null,
state: 'output-error', errorText: error.message }
}
}

validateClientToolOutput(chat-client.ts:1246-1255)在工具带 outputSchema 且它是 Standard Schema(Zod / Valibot / ArkType 等统一接口)时,用 parseWithStandardSchema 校验,否则原样放行。意义:客户端工具返回的脏数据在进入对话前先被 schema 挡一道,坏结果会以 output-error 而不是"看似成功但结构不对"的形态进入消息。

② 送进 processor。 注意它对 error 的处理很小心——只有 output-error 才转发 errorText(且带兜底文案),成功结果上一条游离的 errorText 绝不会被当成错误信号:

// chat-client.ts:1229-1235
this.processor.addToolResult(
result.toolCallId, result.output,
result.state === 'output-error'
? result.errorText || 'Tool execution failed'
: undefined,
)

③ 触发续跑检查——但要看流是否还在进行(这就接上了线③,见 §5):

// chat-client.ts:1238-1243
if (this.isLoading) {
this.queuePostStreamAction(() => this.checkForContinuation())
return
}
await this.checkForContinuation()

4. 线②:工具审批流

4.1 它要解决的小问题

有些工具危险(删数据、花钱),模型想调用时不能直接跑,得先问人ChatClient 本身不弹 UI、不做决策——它只当信使:把"这个工具在等审批"广播出去,再把人的"同意/拒绝"喂回 processor。

4.2 请求侧:把审批需求转成事件

processor 遇到需审批的 tool-call 时回调 onApprovalRequest,ChatClient 把它翻译成一个 devtools/UI 可订阅的 approvalRequested 事件:

// chat-client.ts:404-424(节选)
onApprovalRequest: (args: { toolCallId, toolName, input, approvalId }) => {
const streamId = this.devtoolsBridge.resolveStreamId()
const messageIdForApproval =
this.findMessageIdForToolCall(args.toolCallId) ??
this.currentMessageId ?? ''
this.events.approvalRequested(
streamId, messageIdForApproval,
args.toolCallId, args.toolName, args.input, args.approvalId,
)
}

注意这里出现了两个不同的 id,不能混:

id是什么谁用
toolCallId这次工具调用的 idprocessor / 消息 parts
approvalId(即 approval.id)这次审批请求的 idUI 回应时携带

findMessageIdForToolCall(chat-client.ts:578-588)顺带把审批挂到正确的消息上,便于 UI 定位气泡。

4.3 回应侧:按 approval.id 反查 toolCallId

人点了"同意/拒绝",UI 调 addToolApprovalResponse({ id, approved })——注意这里的 idapproval.id,不是 toolCallId(源码注释专门强调,chat-client.ts:1261)。方法要先反查出对应的 toolCallId:

// chat-client.ts:1265-1288(节选)
for (const msg of messages) {
const toolCallPart = msg.parts.find(
(p): p is ToolCallPart =>
p.type === 'tool-call' && p.approval?.id === response.id) // 按 approval.id 匹配
if (toolCallPart) { foundToolCallId = toolCallPart.id; break }
}

this.processor.addToolApprovalResponse(response.id, response.approved)

反查到的 toolCallId 只用于发事件(toolApprovalResponded);真正驱动状态机的是把 response.id + approved 交给 processor.addToolApprovalResponse。之后同样走"流在进行就排队、否则立即"的续跑检查(chat-client.ts:1291-1297),与 §3.3 的收尾逻辑完全一致。

为什么审批也要触发续跑检查? 因为"批准"往往正是解锁工具执行、进而产生 tool-result 的那一步——批准后可能所有工具就都齐了,该自动喂回模型了。这就自然过渡到线③。


5. 线③:agent 续跑循环

5.1 它要解决的小问题

一次流结束时,最后一个 part 可能是 tool-result(服务端工具跑完了,或客户端工具刚落地)。这时对话不该停——应该把工具结果自动再发给模型,让它基于结果继续。但"自动再发"要极其小心两件事:

  • 不该发的时候别发:模型已经说"我讲完了"(finishReason === 'stop'),或者根本没有工具调用(纯文字回复),就不能自动再发。
  • 别重复发:多个工具结果、链式审批可能在很短时间里各自触发一次续跑检查,不能真的连发好几轮。

5.2 触发条件:三个闸门

续跑的决定在 streamResponsefinally 块里做(chat-client.ts:1061-1082),要同时过三道闸:

// chat-client.ts:1066-1070
if (
lastPart?.type === 'tool-result' && // ① 最后一个 part 是工具结果
finishReason !== 'stop' && // ② 模型没说"讲完了"
this.shouldAutoSend() // ③ 确有工具调用且全部完成
) {
await this.checkForContinuation()
}

第三道闸 shouldAutoSend(chat-client.ts:1359-1370)自己又有两个条件——必须有 tool-call(纯文字没什么可续跑的),且所有工具都完成:

// chat-client.ts:1364-1369
if (!lastAssistant) return false
const hasToolCalls = lastAssistant.parts.some((p) => p.type === 'tool-call')
if (!hasToolCalls) return false
return this.processor.areAllToolsComplete()

areAllToolsComplete 由 processor 判断(比如还有工具在等审批、等结果,就返回 false)。直觉:凑齐一整轮工具的结果了,才把这一整批喂回模型,而不是每回来一个就发一次。

5.3 去重:continuationPendingcontinuationSkipped 的重放

checkForContinuation(chat-client.ts:1326-1352)是所有续跑入口的漏斗(finally 块、工具结果落地、审批回应都会调它)。它用两个标志位做去重与"迟到重放":

// chat-client.ts:1328-1350(节选)
if (this.continuationPending || this.isLoading) {
this.continuationSkipped = true // 有别的续跑在跑,我先记一笔,跳过
return
}
if (this.shouldAutoSend()) {
this.continuationPending = true
this.continuationSkipped = false
let succeeded = false
try { succeeded = await this.streamResponse() }
finally { this.continuationPending = false }
// 若刚才有一次被跳过、且本轮成功,清标志并再查一次(链式审批场景)
if (this.continuationSkipped && succeeded) {
this.continuationSkipped = false
await this.checkForContinuation()
}
}

用一张表看清这两个标志位的分工:

标志位含义何时置位作用
continuationPending当前正有一轮续跑在跑进入续跑前置 true,finally 置回 false挡住并发的重复续跑
continuationSkipped续跑进行期间又来了一次请求,被跳过了命中"已在跑"分支时置 true记账,待本轮成功后重放一次

为什么需要重放? 想象"链式审批":流还在跑时,人连续批准了两个工具。第一个批准触发续跑(continuationPending=true),第二个批准这时来查,发现有续跑在跑,只能标记 continuationSkipped=true 后退出。若不重放,第二个批准的后续就永远丢了。所以本轮续跑成功后,专门补一次 checkForContinuation 把被跳过的那次接上。注意重放只在 succeeded 时做——中止或出错的流不该再引发续跑。

5.4 流进行中来的请求:postStreamActions 排队

续跑要求"没有别的流在跑"。但工具结果、审批回应完全可能在流还没结束时到达。此时不能立刻续跑(会撞上正在跑的流),于是把动作排队,等流的 finally 里再排空:

// 入队(chat-client.ts:1238-1240 等处)
if (this.isLoading) {
this.queuePostStreamAction(() => this.checkForContinuation())
return
}
// 排空(chat-client.ts:1310-1321)
private async drainPostStreamActions(): Promise<void> {
if (this.draining) return // 防重入
this.draining = true
try {
let action
while ((action = this.postStreamActions.shift()) !== undefined) {
await action()
}
} finally { this.draining = false }
}

drainPostStreamActionsfinally 里、续跑判断之前被调用(chat-client.ts:1056),draining 标志防止排空过程自身被重入。append 在流进行中被调用时也走同一条排队通道(chat-client.ts:835-840),保证"流进行中收到的新意图"统一延后到干净的时刻执行。

这三条(§3、§4、§5)其实是一台状态机的三个入口,漏斗都是 checkForContinuation 记住这张流转:

工具结果落地 ┐
审批被回应 ├─► isLoading?──是─► queuePostStreamAction ─┐
流 finally ┘ │否 │
▼ ▼
checkForContinuation ◄──── drainPostStreamActions

continuationPending / isLoading?──是─► 记 continuationSkipped,跳过
│否

shouldAutoSend? ──是─► streamResponse(再来一轮)
│ └ 成功且曾跳过 ─► 重放一次

停止

6. 线④:持久化与"边流边清"抑制,以及多端生成态

这条线全在 client-persistor.ts(外加 chat-client.ts 里几处调用)。它有三块:有序写队列 + 陈旧写丢弃边流边清抑制多端共享生成态

6.1 有序写队列 + 用 generation 丢弃陈旧写

每次消息变化,ChatClient 通过 processor 的 onMessagesChange 通知 persistor(chat-client.ts:236-239),后者把一次 setItem排进一条串行队列。适配器可能是异步的(远端 KV、IndexedDB),多次写必须有序、且后来的删除/新会话要能作废尚未落地的旧写。这靠一个 generation 计数器:

// client-persistor.ts:123-137,notifyMessagesChanged
notifyMessagesChanged(messages) {
this.messagesGeneration++
if (this.skipNextPersist) { this.skipNextPersist = false; return } // 跳过清空快照
const generation = this.generation
const messagesSnapshot = [...messages]
this.runOperation(() => {
if (generation !== this.generation) return // 陈旧写:自我作废
return this.adapter.setItem(this.id, messagesSnapshot)
})
}
// client-persistor.ts:140-148,remove
remove() {
const generation = ++this.generation // 递增 generation:一切在途写作废
this.runOperation(() => {
if (generation !== this.generation) return
return this.adapter.removeItem(this.id)
})
}

runOperation(client-persistor.ts:150-181)把操作接到 this.queue 这条 Promise 链后面,保证严格有序;所有适配器调用都被 .catch 吞掉——存储出问题绝不能弄崩聊天(这是全文反复强调的"best-effort"原则)。

另有一个 messagesGeneration(client-persistor.ts:103-112),给异步 hydration 用:构造时若 getItem 返回的是 Promise,等它 resolve 时要先检查"这期间消息列表有没有动过",没动才 applyMessages,避免慢吞吞的初始加载覆盖掉用户已经开始的新对话。

6.2 边流边清:被清掉的 run,迟到 chunk 要压住

最难的一块。 场景:一个流还在源源不断吐 chunk,用户此刻点了"清空"。清空后状态是空的,但订阅循环里那些属于已清 run 的迟到 chunk 还在往里灌——若不拦,它们会把刚清空的对话又拼回来。

clear() 时先给 persistor 拍一张"被清了什么"的快照:

// chat-client.ts:1167-1172,clear() 里
this.persistor.snapshotClear({
messages: this.processor.getMessages(),
activeRunIds: this.activeRunIds,
currentRunId: this.currentRunId,
})

snapshotClear(client-persistor.ts:191-207)把当时所有消息 id 记进 clearedMessageIds、所有活跃 run 记进 clearedRunIdsignoredActiveRunIds。随后 beginClear()(client-persistor.ts:210-212)置 skipNextPersist,让"清空产生的那个空快照"不被写盘,紧接着 remove() 把存储里的对话删掉(chat-client.ts:1181-1184)。

之后订阅循环每收一个 chunk,都先问 persistor shouldIgnoreChunk:

// chat-client.ts:678-688,consumeSubscription 里
const shouldIgnore = this.persistor?.shouldIgnoreChunk(chunk) ?? false
if (shouldIgnore) {
if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
if (getChunkRunId(chunk)) this.updateRunLifecycle(chunk, { resolveProcessing: false })
else this.drainIgnoredRunlessChunk(chunk)
}
continue // 不喂给 processor
}

shouldIgnoreChunk(client-persistor.ts:215-258)是一组由粗到细的匹配,命中任一即压住。怎么读下面这张判定顺序:从上往下,命中即返回"忽略"。

chunk 有 runId 且 ∈ clearedRunIds? ──► 忽略(顺便标记它的 msg/tool id)
│否
chunk 有 runId 且 ∈ ignoredActiveRunIds? ──► 忽略
│否
无 runId 但属于"当前被忽略的 runless run"?──► 忽略
│否
chunk 的 toolCallId ∈ clearedToolCallIds? ──► 忽略
│否
chunk 的 parentMessageId ∈ clearedMessageIds? ──► 忽略(并记下其 toolCallId)
│否
chunk 的 messageId ∈ clearedMessageIds? ──► 忽略
│否
──► 放行

这里有两个巧妙点:

① 传染式标记(markIgnoredChunkIds)。 一旦某 chunk 因 run 被清而忽略,就把它携带的 messageId/toolCallId 也加进 cleared 集合(client-persistor.ts:306-315)。这样即便后续某个 chunk 不带 runId、只带 messageId,也能凭"这个 message 属于被清的 run"继续压住。抑制会顺着 id 传染,堵住迟到 chunk 的各种变体。

② runless chunk 的归属。 有些适配器在内容事件上省略 runId(只有 RUN_STARTED 带)。persistor 用 currentRunlessRunId 记住"当前该把无 runId 的内容算给哪个被忽略的 run"(onRunStarted 设置,client-persistor.ts:264-266);isRunlessChunkFromIgnoredRun(client-persistor.ts:317-336)则据此判断一个无 runId 的内容 chunk(TEXT/TOOL_CALL/MESSAGES_SNAPSHOT/RUN_ERROR 等)是否该压住。

run 结算时要推进这个指针,避免两个被清的 run 并发排空时误伤另一个:

// client-persistor.ts:269-276,onRunSettled
onRunSettled(runId) {
this.ignoredActiveRunIds.delete(runId)
this.clearedRunIds.delete(runId)
if (this.currentRunlessRunId === runId)
this.currentRunlessRunId =
this.ignoredActiveRunIds.values().next().value ?? null // 指向下一个仍被忽略的 run
}

而一个不带 runId 的 RUN_ERROR(会话级错误)比较特殊:客户端侧 drainIgnoredRunlessChunk(chat-client.ts:452-459)调 takeRunlessRunId(client-persistor.ts:293-304)取出并遗忘当前 runless run,同时把 activeRunIds 里对应项删掉、resolveProcessing() 解开等待——否则 streamResponse 会一直挂在 processingComplete 上。takeRunlessRunId 同样会把指针推进到"下一个仍被忽略的 run",这样并发的两个被清 run 里,排空其一不会停止压制另一个的 runless 内容。

6.3 多端共享生成态:sessionGenerating

最后一块:isLoading本请求本地的("我这次发的请求在不在流")。但同一个会话若挂着 subscribe 订阅,别的标签页/设备发起的生成,本端也会通过订阅收到 run 事件——这种"共享会话此刻在不在生成"由 sessionGenerating 表达。

只由 run 生命周期驱动,与本地请求无关。核心在 updateRunLifecycle(chat-client.ts:461-490):

// chat-client.ts:465-490(节选)
if (chunk.type === 'RUN_STARTED') {
const chunkRunId = getChunkRunId(chunk) ?? chunk.runId
this.activeRunIds.add(chunkRunId)
this.persistor?.onRunStarted(chunkRunId)
this.setSessionGenerating(true) // 有 run 开跑 → 生成中
return
}
// RUN_FINISHED / RUN_ERROR:
const runId = getChunkRunId(chunk)
if (runId) { this.activeRunIds.delete(runId); this.persistor?.onRunSettled(runId) }
else if (chunk.type === 'RUN_ERROR') { // 无 runId 的 RUN_ERROR = 会话级错误
this.activeRunIds.clear()
this.persistor?.onSessionRunError()
}
this.setSessionGenerating(this.activeRunIds.size > 0) // 还有活跃 run 就仍算生成中

activeRunIds 是一个 run id 集合;sessionGenerating = activeRunIds.size > 0setSessionGenerating(chat-client.ts:520-525)只在值真的变化时才回调 onSessionGeneratingChange 并发快照,避免抖动。对外通过 getSessionGenerating()(chat-client.ts:1413-1415)暴露。

一句话对比这两个"生成中":

状态语义数据来源
isLoading我这次 sendMessage 的请求还在流本地请求生命周期
sessionGenerating这个共享会话当前有任意 run 在跑(可能来自别的标签页/设备)订阅到的 RUN_STARTED/FINISHED/ERROR

updateRunLifecycle 被刻意放在一个地方同时服务"正常路径"和"被忽略 chunk 的路径"(chat-client.ts:678-698 两条路都调它),就是为了让两条路对 activeRunIds/sessionGenerating 的处理不会分叉stop/unsubscribe/clear/updateOptions 换连接等场景则通过 resetSessionGenerating(chat-client.ts:527-531)清空 activeRunIds 并让 persistor 也 resetIgnored,把共享态归零。


7. 巧妙之处(可带走的技术)

  • 执行时刻捕获上下文,而非回来时刻。 客户端工具在 execute 发起前就抓 runEventContext(chat-client.ts:358-359),迟到的结果因此永远报在正确的 run 名下。凡是"异步产物要归属发起时上下文"的场景,都能复用这招。
  • 两张工具表(快照 vs 实时)。 activeClientTools 是流开始时的快照(chat-client.ts:915),clientToolsRef.current 是实时注册表(chat-client.ts:191)。流中途换工具不污染本次流——"进行中的任务用它开始时的配置"。
  • 单调 generation 作废在途异步。 persistor 用递增计数器让"删除/新会话"作废所有尚未落地的旧写(client-persistor.ts:129-135141);streamResponse 同样用 streamGeneration(chat-client.ts:856)防止被取代的旧流清理掉新流的状态。同一个模式,两处用。
  • 抑制沿 id 传染。 边流边清时,一个 chunk 被忽略会把它的 message/tool id 也加进 cleared 集合(client-persistor.ts:306-315),于是后续"只带 message id、不带 run id"的迟到 chunk 也能被顺藤摸瓜压住。
  • 续跑漏斗 + 跳过重放。 所有续跑意图汇入 checkForContinuation,用 continuationPending/continuationSkipped 既防重复发、又能在链式审批下把被跳过的那次补上(chat-client.ts:1326-1352)——只在成功后重放,失败/中止不续跑。
  • 本地态与共享态分离。 isLoading(本请求)和 sessionGenerating(共享会话,由 run 事件驱动)彻底分开,让"跨标签页/设备正在生成"成为一等状态(chat-client.ts:520-5251413-1415)。

8. 边界与局限(诚实)

  • 持久化是尽力而为,不保证。 所有适配器调用都被 .catch 吞(client-persistor.ts:113-115152-180)——存储失败静默,聊天照常但那次写就丢了。这是刻意取舍:宁可丢一次写,也不让存储错误弄崩 UI。
  • 多端"生成态"只反映 run 事件,不搬运内容。 sessionGenerating 告诉你"别处在生成",但本端要看到那些消息,仍依赖订阅把对应 chunk 送来并通过抑制关卡——本章不涉及跨端内容同步的完整性,只涉及生成态这一个布尔量。
  • runless 适配器的归属是启发式。 无 runId 内容靠 currentRunlessRunId 单指针归属(client-persistor.ts:60317-336)。代码通过 onRunSettled/takeRunlessRunId 推进指针来应对两个被清 run 并发的情形,但这套逻辑本质是对"适配器省略 runId"的补偿——带 runId 的适配器天然更稳
  • 续跑防重依赖异步时序假设。 continuationPending/continuationSkippedawait 期间被异步改写(源码专门加了 lint 抑制注释,chat-client.ts:1346),其正确性建立在单事件循环的执行顺序上;它不是通用锁。
  • 续跑失败仅打日志。 checkForContinuation 抛错时上层只 console.error(chat-client.ts:1073-1074),不额外重试——避免坏工具把对话拖进无限续跑。

9. 横向对比

同 shelf 的其它聊天/agent-UI 项目也要处理"工具结果回填 + 续跑 + 落盘",但取舍不同:

  • assistant-ui / copilotkit 等更偏"框架内建"的方案,常把工具执行与审批做进 React 状态树;TanStack AI 则把这些编排全压在框架无关的 ChatClient里,框架层(第 4 章)只是薄适配,续跑/审批/持久化对 React/Solid/Vue/Svelte 完全一致。
  • 多端共享生成态(sessionGenerating)在多数客户端 SDK 里并不是一等概念——TanStack AI 因为把连接抽象成可长期 subscribe 的通道(第 2 章),才顺势把"别的端在生成"变成可观测状态。

10. 代码地图(导航索引)

主题文件符号
客户端工具自动执行入口packages/ai-client/src/chat-client.tsonToolCall(StreamProcessor events 回调)
工具表快照 vs 实时表packages/ai-client/src/chat-client.tsactiveClientToolsclientToolsRef
待办执行追踪packages/ai-client/src/chat-client.tspendingToolExecutions
迟到结果归属上下文packages/ai-client/src/chat-client.tsgetCurrentRunEventContextrunEventContext
工具结果落地 + schema 校验packages/ai-client/src/chat-client.tsaddToolResultForClientToolvalidateClientToolOutput
收尾等待客户端工具packages/ai-client/src/chat-client.tspendingToolExecutions(streamResponse finally 前)
审批请求转事件packages/ai-client/src/chat-client.tsonApprovalRequestfindMessageIdForToolCall
审批回应反查 toolCallIdpackages/ai-client/src/chat-client.tsaddToolApprovalResponse
续跑漏斗 + 去重重放packages/ai-client/src/chat-client.tscheckForContinuationcontinuationPendingcontinuationSkipped
续跑闸门packages/ai-client/src/chat-client.tsshouldAutoSendareAllToolsComplete
流中动作排队packages/ai-client/src/chat-client.tsqueuePostStreamActiondrainPostStreamActions
持久化写队列 + 陈旧丢弃packages/ai-client/src/client-persistor.tsnotifyMessagesChangedremoverunOperationgeneration
异步 hydration 防覆盖packages/ai-client/src/client-persistor.tshydrateAsyncmessagesGeneration
边流边清抑制packages/ai-client/src/client-persistor.tssnapshotClearbeginClearshouldIgnoreChunkmarkIgnoredChunkIds
runless chunk 归属packages/ai-client/src/client-persistor.tscurrentRunlessRunIdisRunlessChunkFromIgnoredRunonRunSettledtakeRunlessRunId
会话级错误排空packages/ai-client/src/chat-client.tsdrainIgnoredRunlessChunk
多端共享生成态packages/ai-client/src/chat-client.tsupdateRunLifecyclesetSessionGeneratingactiveRunIdsgetSessionGenerating
生成态归零packages/ai-client/src/chat-client.tsresetSessionGeneratingresetIgnored