决策循环:后端的大脑如何挑组件并流式吐 props
30 秒导读: 用户发来一句话,后端得决定「回什么文字、显示哪个 UI 组件、组件里填什么数据、还要不要调用工具」。这一章讲的
runDecisionLoop就是干这件事的核心算法——它把注册好的组件和工具喂给 LLM,然后一边接收 LLM 的流式输出、一边把还没吐完的 JSON 解析成组件 props,让前端能在模型还在打字时就先把组件画出来。
本章只讲后端的决策核心。上游「组件和工具怎么变成 LLM 能调用的东西」见 01-component-as-tool;下游「AG-UI 事件怎么累积成状态」见 03-agui-streaming;「HTTP 怎么传、客户端工具循环怎么转」见 04-message-lifecycle。
1. 先建直觉:决策循环要解决什么
假设你在一个天气 App 里问 AI:「北京今天天气怎么样?」后端手里有这些能力:一个能查天气的工具 get_weather,一个能把天气画在屏幕上的组件 Weather。
后端要替 LLM 把一连串决定串起来:
- 要不要先调
get_weather拿数据? - 拿到数据后,要不要显示
Weather组件? - 显示的话,组件的
props(城市、温度、图标)填什么? - 同时回给用户什么文字?
决策循环 = 把「模型的一次流式输出」翻译成上面这几个决定的机器。 它的输入是聊天历史 + 一批工具,输出是一串随时间增长的「组件决策」(DecisionStreamItem)。
它的一句话精华在于流式:模型吐 props 的 JSON 是一个字符一个字符来的,决策循环不等它吐完,而是每来一小段就尝试解析一次,让前端能提前渲染出「正在成形」的组件。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从左到右是一次 runDecisionLoop 调用的数据流;虚线框是「对每个流式 chunk 重复做」的循环体。
输入: 聊天历史 messages + 一批工具 strictTools
│
▼
┌───────────────────────────┐
│ ① 工具分流 + 加标准参数 │ UI工具(show_component_*) / 信息工具
│ + 组装 system prompt │ 每个工具补上 _tambo_statusMessage 等
└───────────────────────────┘
│
▼
┌───────────────────────────┐
│ ② LLMClient.complete │ stream:true,返回异步流
│ (流式,带 tool_choice) │
└───────────────────────────┘
│ 每来一个 chunk ↓ (循环体)
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
③ partial-json 解析 把「还没吐完」的 arguments 解析成
tool_call.arguments in-progress 的 props
│ │ │
▼
④ 组装 DecisionStreamItem decision(旧格式) + aguiEvents(新格式)
│ │ │
▼ yield
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
│
▼
输出: AsyncIterator<DecisionStreamItem> 一串随时间增长的决策
各部件的一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
runDecisionLoop | 决策循环主函数,async function* 生成器 | services/decision-loop/decision-loop-service.ts:115 |
addParametersToTools / standardToolParameters | 给每个工具补上 Tambo 标准参数 | services/tool/tool-service.ts:148 / :21 |
generateDecisionLoopPrompt | 组装 system prompt(教模型区分工具类型) | prompt/decision-loop-prompts.ts:3 |
LLMClient.complete | 统一的 LLM 流式接口(多家提供方) | services/llm/llm-client.ts:62 |
DecisionStreamItem | 决策循环的产物:旧决策 + AG-UI 事件 | services/decision-loop/decision-loop-service.ts:40 |
3. 主线走一遍:runDecisionLoop 从头到尾
主函数是一个异步生成器 async function* runDecisionLoop(...),签名见 decision-loop-service.ts:115。它拿到 llmClient、messages、strictTools 等参数,分三个阶段:准备 → 发起流式请求 → 边流边解析并 yield。
3.1 准备阶段:分流、加参数、组 prompt
第一步,把工具分成两类。 决策循环只需要挑出「UI 工具」(以 show_component_ 开头的),因为后面判断「模型这次是不是要显示组件」全靠它:
// decision-loop-service.ts:126 —— 真实源码
const componentTools = strictTools.filter((tool) =>
isUiToolName(getToolName(tool)),
);
isUiToolName 就是判断名字是否以 UI_TOOLNAME_PREFIX(即 "show_component_")开头(core/src/ui-tools.ts:1)。注意分流不改动 strictTools 本身,componentTools 只是一个「哪些是 UI 工具」的备查子集;其余的全算「信息工具」。
第二步,给所有工具补上标准参数。 见 decision-loop-service.ts:130 调 addParametersToTools(strictTools, standardToolParameters)——这一步是 Tambo 的一个巧妙设计,§5.2 细讲。
第三步,组装 system prompt。 generateDecisionLoopPrompt(customInstructions, memories) 返回一个带占位符的模板,再用 formatTemplate 把 {user_memories} 之类的变量提前填进去(:145-157)。
一个容易忽略的坑(源码注释点破): 变量替换必须在 prompt 变成 messages 之前做完。因为用户消息里可能带任意
{花括号},如果延后到统一模板处理,会把用户消息里的花括号误当成模板变量(:150-153)。
准备阶段收尾:把 system prompt 包成一条合成的 MessageRole.System 消息(id 写死为 "synthetic-system-message-id",因为它不入库),拼到聊天历史前面,得到 promptMessages(:175-189)。
3.2 发起流式请求
// decision-loop-service.ts:191 —— 真实源码(节选)
const responseStream = await llmClient.complete({
messages: promptMessages,
tools: toolsWithStandardParameters,
stream: true,
tool_choice: convertToolChoice(forceToolChoice),
abortSignal,
providerSkills,
});
关键是 stream: true —— 返回的是一个 AsyncIterableIterator<LLMStreamItem>,能一段段拿。tool_choice 由 convertToolChoice 把上层传入的 forceToolChoice 翻译成 OpenAI 的取值(§5.4)。
3.3 循环体:边流边解析并 yield
主循环 for await (const streamItem of responseStream)(:215)对每个 chunk做同一件事,并且用一个 accumulatedDecision 把结果累积起来——后面来的 chunk 覆盖/补全前面的字段(:296-299)。整个循环体包在 try/catch 里:单个 chunk 解析失败只打日志、不中断整条流(:306-308)。
循环体的核心逻辑就是 §4 要拆开讲的四小步。
4. 循环体拆解:一个 chunk 里发生了什么
每个 chunk 到手,决策循环依次做四件事。先看整体,再逐个细讲。
| 步 | 做什么 | 关键调用 |
|---|---|---|
| a | 取出这轮的文本和(至多一个)工具调用 | getLLMResponseMessage / tool_calls?.[0] |
| b | 用 partial-json 解析没吐完的 arguments | parse(toolCall.function.arguments) |
| c | 抽出状态消息、过滤掉标准参数、判断是不是 UI 工具 | filterOutStandardToolParameters |
| d | 组装 DecisionStreamItem 并 yield | buildToolCallRequest / extractComponentIdFromEvents |