跳到主要内容

第 3 章 · run loop、工具循环与流式

本章讲什么: 一次对话从「发送」到「结束」之间到底发生了什么。这里是整个项目作为「agent UI」最核心的逻辑:工具调用循环。读懂 _runLoopshouldContinue 两个东西,就懂了 assistant-ui 怎么让模型「自己连着调几次工具直到完成」。

3.1 一次回复 = 一个 do-while 循环

直觉先行:一个会用工具的 agent,一轮可能不够。模型可能先说「我要查天气」(发出一个 tool-call),你执行工具、把结果喂回去,模型才接着说出最终答案。所以「一次用户提问 → 一条完整回复」中间可能夹着好几个回合(roundtrip)

assistant-ui 把这建模成一个 do...while 循环。看 local runtime 的 _runLoop(packages/core/src/runtimes/local/local-thread-runtime-core.ts:344-368):

// 真实源码片段:local-thread-runtime-core.ts:360-368
do {
message = await this.performRoundtrip( // 跑一回合:调适配器、累积流
parentId,
message,
runConfig,
runCallback,
);
runCallback = undefined;
} while (shouldContinue(message, this._options.unstable_humanToolNames));
  • performRoundtrip = 调一次 ChatModelAdapter.run,把流式结果累积进 message
  • shouldContinue = 看这一回合结束后,是不是该再跑一轮

这个循环就是 agent 的心脏。

3.2 startRun:循环开始前先放一条占位 assistant 消息

循环之前,startRun(local-thread-runtime-core.ts:318-342)先乐观地插入一条空的 assistant 消息,status: { type: "running" }content: [](local-thread-runtime-core.ts:326-339)。

为什么先插空消息?因为 UI 要立刻显示「助手正在输入」的气泡,不能等后端第一个 token 才出现。流式 chunk 到了再往这条消息的 content 里填。这条占位消息的 metadata 还带上了当前 unstable_state、空的 steps 等(local-thread-runtime-core.ts:331-337)。

3.3 shouldContinue:工具循环的判定逻辑

整个工具循环要不要继续,全压在 shouldContinue 这一个纯函数上。它在 packages/core/src/runtimes/local/should-continue.ts:3-37。逻辑层层把关:

第一关——只有「等工具结果」才可能继续(should-continue.ts:7-13):如果消息状态不是 requires-action 且原因不是 tool-calls,直接返回 false(没工具要等,自然结束)。

第二关——有待审批的工具就停(should-continue.ts:15-22):只要存在一个工具调用「还没结果、有 approval、且审批未决」,就返回 false——把控制权交还给用户(等他批准)。这正是人在环的拦截点。

第三关——所有工具结果都到齐了才继续(should-continue.ts:30-36):

// 真实源码片段:should-continue.ts:30-36
return result.content.every(
(c) =>
c.type !== "tool-call" ||
!!c.result || // 有结果了
c.approval !== undefined || // 或走审批通道
!humanToolNames.includes(c.toolName), // 或不是「需人类」的工具
);

一句话:当且仅当本轮所有工具调用都已就绪(有结果、或不需要等),才再跑一轮让模型基于工具结果继续生成。

怎么读这张工具循环图(从上往下,命中分叉就跳出)

用户发消息


startRun:插入空 assistant 消息(running)


┌───────────── do ─────────────────────────┐
│ performRoundtrip:调 adapter.run, │
│ 流式累积到 message.content │
└───────────────────────────────────────────┘


shouldContinue(message)?

├─ 不是 requires-action/tool-calls ─► 结束(模型说完了)
├─ 有待审批工具 ──────────────────► 停,等用户批准(人在环)
├─ 有工具还没结果 ───────────────► 停,等工具/人类输入
└─ 所有工具都就绪 ───────────────► 回到 do,再跑一轮

3.4 收尾:建议(suggestions)

循环结束后(finally 块,local-thread-runtime-core.ts:369-376)发出 runEnd 事件、释放队列。然后如果配了 suggestion 适配器且消息不再 requires-action,会去生成后续建议(local-thread-runtime-core.ts:378-399)——就是聊天框上方那种「你可能还想问…」。注意它也支持流式(适配器返回 async generator 时逐条出),并用一个 AbortController 保证新一轮开始时能取消旧的建议生成。

3.5 流式:chunk 怎么变成消息

「逐字打印」的本质是:后端传来一串 chunk,前端把它们累积成一条不断变长的消息,每变一次就通知 UI。

这件事由独立的 @assistant-ui/assistant-stream 包负责。它的公共出口(packages/assistant-stream/src/index.ts)里有几个关键角色:

  • createAssistantStream / AssistantStreamController —— 构造与控制一条助手流。
  • AssistantMessageAccumulator / AssistantMessageStream(index.ts:5-9index.ts:33)—— 把流式 chunk 累积成 AssistantMessage 的累加器。
  • 一组序列化编解码器:DataStreamDecoder/EncoderAssistantTransportDecoder/EncoderUIMessageStreamDecoderPlainTextDecoder/Encoder(index.ts:14-32)——对应不同后端的 wire 格式。

直觉:adapter 把后端的字节流解码成统一的 chunk,accumulator 把 chunk 合并进消息的 content(文本拼接、工具调用 args 增量解析……),runtime 每次合并后 notifySubscribers,store 层就把变化推给订阅了那一小块状态的 primitive。流式片段的「部分解析」语义(args 字段可能没到齐)就是在这一层产生的,呼应第 2 章 ToolCallMessagePart 的注释。

(本章对 assistant-stream 内部的具体合并算法只读到了模块边界,未逐行追;此处描述以其公共 API 的存在与命名为依据。)

3.6 巧妙之处

  • 工具循环是纯函数判定。 「要不要再跑一轮」抽成一个无副作用的 shouldContinue,既好测试也好读——把 agent 最容易出 bug 的控制流隔离成一个小函数。
  • 审批 = 提前 return false。 人在环没有单独一套机制,就是工具循环判定里的一个分支:有待审批就停、把球踢回 UI。设计很省。
  • 乐观占位消息。 先插空气泡再填流,UI 永远「先有反馈再有内容」,这是体感流畅的关键。

3.7 代码地图

主题文件符号
run loop(工具循环)packages/core/src/runtimes/local/local-thread-runtime-core.ts_runLoopstartRunperformRoundtrip
是否继续的判定packages/core/src/runtimes/local/should-continue.tsshouldContinue
human-in-the-loop 工具名packages/core/src/runtimes/local/local-runtime-options.tsunstable_humanToolNames
流式累积与编解码packages/assistant-stream/src/index.tsAssistantMessageAccumulatorAssistantStreamControllerDataStreamDecoder