AI Agent 工程化系列第四篇。本文以 Cherry Studio 的 AI SDK Agent、Stream Manager 和 Agent Session Runtime 为例,讲解工具循环的状态、停止条件、Hook、错误、审批与用户 steering。文中的伪代码用于解释工程逻辑,不是可直接复制的生产实现。
写在前面:工具接上了,Agent 开始"停不下来"
当 Chat 只负责问答时,一次请求基本就是"发消息、等回答"。可 Agent Work 接上搜索、读文件、知识库或自动化工具后,模型会不断决定下一步:查一下、再读一点、再调一个工具、再想想。
这时最危险的情况不是模型不会调用工具,而是它调用得太积极:重复重试、在错误分支里绕圈、用户点了停止仍继续跑,或者审批回来后又从头来一遍。
因此,团队研究 Cherry Studio 的 Agent Loop,不是为了给模型多套一层 while,而是想弄清:多步任务怎样有边界地继续、有语义地暂停、可靠地结束。
1. 什么是 Loop Engineering
没有工具时,一次 LLM 调用近似为:
text
输入消息 → 模型生成 → 输出消息
有工具后,模型需要多次决定下一步:
Loop Engineering 的工作是将这条链路变成一个可预测的状态机,而不是让模型"想调用几次就调用几次"。
它需要回答:
- 每一步的输入、输出和状态由谁拥有?
- 什么条件下继续、停止、暂停、失败或取消?
- 工具失败、模型失败和用户停止是否有不同语义?
- 用户在运行中插话时,如何不破坏当前执行?
- 如何保证流只完成一次,持久化状态不互相竞争?
2. 回到 Cherry Studio:先分清谁管一轮,谁管一个会话
Cherry Studio 将 模型内工具循环 和 产品级会话循环 分开。
text
产品级会话层
AiStreamManager / AgentSessionRuntimeService
- topic、订阅者、持久化、steer、审批、恢复
模型内工具循环
AI SDK Agent / Claude Code Runtime Driver
- 模型调用、工具调用、步骤、stopWhen、Hooks
Agent 不知道 topic、IPC、SQLite 持久化或多模型扇出;它只负责一次模型流中的工具循环。AiStreamManager 才是 Chat 侧 topic 生命周期的拥有者。
这种拆分避免把 UI 和数据库语义塞进模型循环,也让不同 Agent Driver 可以复用相同的产品级流协议。简单说,模型循环专心把这一轮跑完,产品运行时负责不让整场会话乱套。
3. 基础状态机:跑起来以后,结局不只有"成功"或"报错"
一个 Agent Loop 不应只区分"完成"和"报错"。至少需要这些状态:
text
running
├─ success 正常生成最终结果
├─ awaiting-approval 工具等待用户授权
├─ paused 用户停止,保留部分结果
├─ error 不可恢复失败
└─ steer-yield 为排队的用户 follow-up 让出当前 turn
模型循环内部还会经历多个 step:
text
step n
→ prepareStep
→ model call
→ zero or more tool calls
→ tool execution
→ onStepFinish
→ evaluate stop conditions
简化伪代码:
ts
async function runToolLoop(initialState, signal) {
state = initialState
while (true) {
assertNotAborted(signal)
stepInput = await prepareStep(state)
modelOutput = await callModel(stepInput, signal)
if (modelOutput.hasToolCalls) {
toolResults = await executeTools(modelOutput.toolCalls, signal)
state = appendToolResults(state, toolResults)
} else {
state = appendAssistantOutput(state, modelOutput)
}
await onStepFinish(state.lastStep)
outcome = evaluateStop(state, signal)
if (outcome !== "continue") {
return outcome
}
}
}
真实系统会处理流式 chunk、并行工具、工具调用修复和 provider 异常,但状态机语义应保持清晰。
4. 关键算法一:停止条件的组合
停止条件不只有 达到最大步数 。Cherry Studio 将基础 step cap 与 feature 贡献的条件组合为一个有效 stopWhen。
text
effectiveStopWhen = stepCap OR featureStopCondition1 OR featureStopCondition2 ...
概念实现:
ts
function composeStopWhen(baseStopWhen, featureConditions) {
conditions = []
if (baseStopWhen exists) {
conditions.push(baseStopWhen)
} else {
conditions.push(defaultStepCap())
}
conditions.push(...featureConditions)
return (context) => conditions.some(condition => condition(context))
}
这里有两个关键细节。
4.1 显式 stopWhen 不能意外取消默认 cap
很多 SDK 将『传入任意 stopWhen』解释为『调用方完全接管停止逻辑』。如果 feature 只加入一个与 step 数无关的条件,却没有重新加上 step cap,工具循环就可能失去上限。
正确的策略是:
ts
function ensureStepCap(baseStopWhen, featureConditions) {
hasExplicitCap = baseStopWhen.includesStepCap()
if (!hasExplicitCap) {
return [defaultStepCap(), ...baseStopWhen, ...featureConditions]
}
return [...baseStopWhen, ...featureConditions]
}
这是一类典型的组合型 Bug:每个 feature 单独看都正确,组合后却移除了系统安全边界。
4.2 停止原因必须被分类
stopWhen === true 并不一定表示"模型正常完成"。可能是:
- 达到最大工具步骤。
- 当前 turn 为处理已排队 steer 而让出。
- 受信任本地工具返回 terminal failure。
- SDK 自身认为完成。
因此停止后还必须做一次结果分类:
ts
function resolveTerminalOutcome(steps, stopWhen) {
if (hasTrustedTerminalToolFailure(steps)) {
return error("Tool reported terminal failure")
}
if (didStepCapActuallyFire(stopWhen, steps)) {
return error("Agent exceeded the step budget")
}
if (didSteerYieldFire(steps)) {
return steerYield()
}
return success()
}
特别重要的是,step cap 应记录"它是否实际触发"。不能只因为配置中存在 cap,就把审批暂停或 steer yield 误判为预算耗尽。
5. 关键算法二:Hook 的确定性组合
成熟 Agent 通常有多个 Hook 来源:
- 内部观察者,例如 usage 统计。
- Request Feature,例如 Provider 特性或产品级 stop condition。
- 调用方 Hook,例如 analytics。
这些 Hook 不能随意 Promise.all 并发执行,因为它们可能共享顺序依赖、状态或外部副作用。
Cherry Studio 的 Hook 组合遵循三种不同规则。
5.1 Void Hook:顺序执行,单个失败不阻断其余观察者
适用于 onStart、onStepFinish、工具开始/结束、onFinish 和 onAbort。
ts
function chainVoid(hooks) {
return async (...args) => {
for (hook of hooks) {
try {
await hook(...args)
} catch (error) {
logHookFailure(hook, error)
// 继续执行后续观察者
}
}
}
}
顺序执行的收益:
- usage 汇总可先于最终 flush。
- 监控、持久化和 UI 元数据的相对顺序稳定。
- 某个非关键观察者失败不会让 Agent 主流程丢失结果。
5.2 prepareStep:将前一个 Hook 的输出交给下一个 Hook
prepareStep 是变换管线,而非广播事件。
ts
function chainPrepareStep(hooks) {
return async (input) => {
current = input
for (hook of hooks) {
patch = await hook(current)
if (patch exists) {
current = mergeStepPreparation(current, patch)
}
}
return current
}
}
典型用途:
- 为下一 step 增加 Provider 特定参数。
- 根据工具状态修改 active tools。
- 为流式工具参数补充修复策略。
如果把它实现成每个 Hook 都接收原始 input,后一个 Hook 将看不到前一个 Hook 的决策,组合能力就会退化。
5.3 onError:收集策略,但当前不执行重试
ts
async function composeOnError(hooks, context) {
action = "abort"
for (hook of hooks) {
result = await hook(context)
if (result === "retry") {
action = "retry"
}
}
return action
}
当前 retry 是预留信号,并不代表运行时已经安全支持自动重试。真正的 retry 需要定义幂等性、工具副作用、预算、退避和重复消息策略;在这些条件未具备前,"收到 retry 信号仍中止"比隐式重放副作用更安全。
6. 关键算法三:围绕工具执行建立可观测边界
模型 SDK 通常提供模型调用或工具输入流事件,却未必提供"工具 execute 开始---结束"的完整边界。若没有这个边界,无法准确统计:
- 工具自身耗时。
- 参数校验、审批等待与实际执行耗时的区别。
- 单个工具失败率。
- Tool result 对后续 step 的影响。
Cherry Studio 为工具 execute 加包装:
ts
function wrapTool(tool, hooks) {
if (!tool.execute) {
return tool
}
return {
...tool,
async execute(input, context) {
event = {
toolName: context.toolName,
input,
startedAt: now()
}
await hooks.onToolExecutionStart?.(event)
try {
output = await tool.execute(input, context)
await hooks.onToolExecutionEnd?.({
...event,
output,
status: "success",
durationMs: now() - event.startedAt
})
return output
} catch (error) {
await hooks.onToolExecutionEnd?.({
...event,
error,
status: "error",
durationMs: now() - event.startedAt
})
throw error
}
}
}
}
注意:审批等待不是普通工具执行耗时的一部分。它应单独从 approval request 到用户批准/拒绝计时,否则工具性能指标会被人工等待时间污染。
7. 关键算法四:终态只能提交一次
流式系统最危险的竞态之一是:
text
模型发送 finish
与此同时用户点击 Stop
若先把 finish 广播为 success,再处理 abort,UI、持久化记录和 usage 统计会出现矛盾终态。
Cherry Studio 在 Agent.stream 中使用"终态认领"与 finish commit 边界。
ts
terminalOutcome = "running"
function claimTerminalOutcome(next) {
if (terminalOutcome !== "running") {
return false
}
terminalOutcome = next
return true
}
async function commitFinish(finishChunk, signal) {
onAbort = () => {
if (claimTerminalOutcome("abort")) {
terminateReadableSide()
}
}
signal.once("abort", onAbort)
try {
if (signal.aborted) onAbort()
if (terminalOutcome === "abort") return false
await writer.write(finishChunk)
return terminalOutcome === "success"
} finally {
signal.removeListener("abort", onAbort)
}
}
核心不变量:
text
终态从 running 只能迁移一次:
running → success
running → abort
这相当于一个轻量 compare-and-set。它避免"finish 已写入但 abort 又覆盖"的双终态问题。
7.1 为什么要暂存 finish chunk
SDK 的 finish chunk 看起来像成功,但 Loop 还需要:
- 等待所有 step 元数据可用。
- 检查 stop condition 是否因 cap 或 steer 触发。
- 检查工具是否返回受信任 terminal failure。
- 确认没有 abort 竞争。
因此正确顺序是:
ts
for await (chunk of uiStream) {
if (chunk.type === "finish") {
pendingFinish = chunk
continue
}
write(chunk)
}
terminal = resolveTerminalOutcome(await result.steps, stopWhen)
if (terminal.isError) throw terminal.error
if (terminal.isAbort) callOnAbort()
if (terminal.isSuccess) commitFinish(pendingFinish)
这防止前端短暂显示成功、随后又收到"步数耗尽"或工具终态错误。
8. 关键算法五:可信的 terminal tool failure
工具输出是模型可见数据,不应该让任意 JSON 字段控制运行时。否则外部 MCP 或 Provider 执行的工具只要返回:
json
{ "terminal": true, "retryable": false }
就可能意外终止本地 Agent Loop。
正确做法是使用进程本地 provenance 标记:
ts
const terminalMarker = new WeakSet()
function makeTrustedTerminalFailure(message) {
result = {
terminal: true,
retryable: false,
message
}
terminalMarker.add(result)
return result
}
function isTrustedTerminalFailure(value) {
return isObject(value) && terminalMarker.has(value)
}
只有受信任本地工具创建并保留同一对象引用的结果才能终止 Loop。经过序列化、跨 MCP 传输或模型生成的同形 JSON 不会带有这个内存标记。
这是"数据形状不等于权限"的一个例子:安全语义不能只依赖可伪造的 payload。
9. 关键算法六:审批是暂停,不是失败或重新开始
需要审批的工具会把 Loop 推入 awaiting-approval:
产品级流程:
ts
async function executeWithApproval(tool, input, context) {
if (!tool.needsApproval || policy.autoApproves(tool, input)) {
return tool.execute(input, context)
}
approval = createApprovalRequest(tool, input)
persistApprovalRequestedPart(approval)
streamManager.setStatus(context.topicId, "awaiting-approval")
decision = await waitForUserDecision(approval.id)
if (!decision.approved) {
return deniedToolResult(decision.reason)
}
return tool.execute(decision.updatedInput ?? input, context)
}
Cherry Studio 的 Main 是审批状态的唯一写入者。Renderer 展示 approval card 并提交用户决定,不能直接修改消息数据库;否则跨窗口和 overlay/persist 的竞态会让审批卡重新出现或覆盖更新。
对于 Claude-Agent,批准会解除 live canUseTool 等待;MCP 路径则在所有审批决定后发起 continue-conversation。两条传输路径不同,但产品语义相同:审批等待不等于模型错误,也不等于重新执行整轮任务。
10. 关键算法七:用户 steering 的队列与边界
用户可能在 Agent 正在调用工具时发送新要求,例如"不要改文件,只给我方案"。如果把新消息直接插入正在生成的模型上下文,会破坏本轮消息顺序,也无法定义工具执行到哪里应该看到新要求。
普通 Chat 采用"enqueue + yield + chain":
ts
function submitWhileChatIsStreaming(topicId, steerMessage) {
persistUserMessage(topicId, steerMessage)
streamManager.enqueuePendingSteer(topicId, steerMessage.id)
}
function steerYieldStopCondition(context) {
return streamManager.hasPendingSteer(context.topicId)
}
function onExecutionDone(topicId, outcome) {
if (outcome.isCleanSuccess && streamManager.hasPendingSteer(topicId)) {
next = streamManager.dequeuePendingSteer(topicId)
startSteerContinuation(topicId, next)
}
}
执行不是被强制中断,而是在下一个 step 边界干净结束。前一轮持久化为成功,随后启动 continuation 来回答排队的 steer。
若用户 Stop 或发生错误,排队 steer 不会自动链式执行;其持久化用户消息保留在历史中,用户可重新发送。这避免在不完整或错误上下文上继续执行用户副作用。
Agent Session 使用不同策略:Driver 支持 redirect 时,steer 会暂存并在下一次 PreToolUse 时作为 additionalContext 注入;否则进入 pendingTurns,等待下一 turn。两者共同原则是:上下文变更必须发生在已定义的运行边界上。
11. Loop 的预算与可观测性
Loop 必须有预算,否则 Agent 的工具能力会转化为不可预测的成本和时延。
至少应监控:
| 指标 | 用途 |
|---|---|
| step 数 | 识别反复尝试、错误规划和 cap 命中 |
| 工具调用次数与分布 | 识别不必要工具链与高风险工具 |
| 每工具耗时 | 分离网络、计算、审批等待和模型生成瓶颈 |
| 输入/输出 token | 控制模型成本与上下文膨胀 |
| 成功、取消、审批、错误比例 | 识别产品摩擦和运行时可靠性 |
| steer 链长度 | 识别用户频繁纠偏或任务边界不清 |
可观测性需要覆盖完整链路:
text
ai.turn
├─ model step 1
├─ tool execution: search
├─ model step 2
├─ approval wait: write_file
├─ tool execution: write_file
└─ finish / abort / error
不要仅记录最终文本和总耗时;那无法判断失败来自模型规划、工具执行、权限设计还是用户中断。
12. Loop Engineering 的测试策略
12.1 停止条件
ts
test("feature condition 不会移除默认 step cap", () => {
stopWhen = composeStopWhen(undefined, [steerYield])
expect(stopWhen).toContainStepCap()
})
test("cap 真实触发时才报告 budget exhaustion", () => {
outcome = resolveTerminalOutcome(steps, trackedStopWhen)
expect(outcome).toEqual(error("Agent exceeded the step budget"))
})
12.2 Hook 顺序与隔离
ts
test("一个 void hook 失败不会阻断后续 hook", async () => {
calls = []
hook = chainVoid([
() => { throw new Error("observer failed") },
() => calls.push("second")
])
await hook()
expect(calls).toEqual(["second"])
})
test("prepareStep 串行传递前一个 patch", async () => {
prepare = chainPrepareStep([
() => ({ activeTools: ["search"] }),
(input) => ({ headers: { "x-tools": input.activeTools.join(",") } })
])
expect(await prepare({})).toMatchObject({
headers: { "x-tools": "search" }
})
})
12.3 终态与竞态
ts
test("abort 与 finish 竞争时不能同时发布 success", async () => {
run = startStreamWithBlockedFinish()
run.abort()
await run.releaseFinish()
expect(run.terminalOutcome).toBe("abort")
expect(run.publishedFinish).toBe(false)
})
12.4 Steering 与审批
ts
test("排队 steer 在 clean success 后启动 continuation", async () => {
queueSteer("do not write files")
finishCurrentStep()
expect(startedContinuation()).toContain("do not write files")
})
test("审批等待不被当作 step cap 错误", async () => {
pauseForApproval()
expect(currentOutcome()).toBe("awaiting-approval")
})
13. 这些 Loop 坑,最好提前绕开
反模式:只靠 maxSteps
maxSteps 能防无限循环,但不能解释停止原因、处理审批或保证工具副作用安全。应组合预算、状态分类和产品级状态机。
反模式:所有 Hook 并发执行
并发可能打乱 usage 汇总、持久化和 trace 顺序。对共享状态 Hook,应使用确定性的顺序组合。
反模式:收到 SDK finish 就立刻宣布成功
finish 之后仍可能发现 cap、terminal tool failure 或 abort。应先分类 Loop 结果,再提交 finish。
反模式:把外部工具 JSON 当作控制信号
外部返回的数据不可信。需要终止、权限或重试语义时,应使用本地不可伪造的 provenance。
反模式:用户插话直接修改当前模型输入
这会让历史、工具执行和持久化边界无法定义。应使用队列、yield、continuation 或 Driver 定义的 redirect 边界。
反模式:将审批当作一次新的模型请求
审批应恢复被挂起的工具调用或基于已持久化状态继续,而不是丢掉前一轮推理后从头开始。
14. 小结
Loop Engineering 的成熟标志不是"模型能连续调用多个工具",而是系统能够明确说明:
- 当前任务进行到哪一步,为什么继续或停止?
- 工具失败、用户取消、审批等待和预算耗尽分别意味着什么?
- 用户新要求会在什么边界生效?
- 是否能证明终态只发生一次?
- 是否能定位 token、模型、工具、审批和持久化中的性能问题?
一句话总结:
好的 Loop Engineering 不只是让 Agent 能够多走几步,而是让每一步都可预算、可观察、可暂停、可恢复并安全结束。