Loop Engineering:把模型调用变成可控的多步任务

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 复制代码
输入消息 → 模型生成 → 输出消息

有工具后,模型需要多次决定下一步:

flowchart LR input["输入消息"] --> model["模型生成"] model --> toolCall{"是否生成工具调用?"} toolCall -->|是| execute["执行工具"] execute --> observation["将工具结果加入模型可见状态"] observation --> model toolCall -->|否| finalAnswer["生成最终回答"]

Loop Engineering 的工作是将这条链路变成一个可预测的状态机,而不是让模型"想调用几次就调用几次"。

它需要回答:

  1. 每一步的输入、输出和状态由谁拥有?
  2. 什么条件下继续、停止、暂停、失败或取消?
  3. 工具失败、模型失败和用户停止是否有不同语义?
  4. 用户在运行中插话时,如何不破坏当前执行?
  5. 如何保证流只完成一次,持久化状态不互相竞争?

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 来源:

  1. 内部观察者,例如 usage 统计。
  2. Request Feature,例如 Provider 特性或产品级 stop condition。
  3. 调用方 Hook,例如 analytics。

这些 Hook 不能随意 Promise.all 并发执行,因为它们可能共享顺序依赖、状态或外部副作用。

Cherry Studio 的 Hook 组合遵循三种不同规则。

5.1 Void Hook:顺序执行,单个失败不阻断其余观察者

适用于 onStartonStepFinish、工具开始/结束、onFinishonAbort

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 还需要:

  1. 等待所有 step 元数据可用。
  2. 检查 stop condition 是否因 cap 或 steer 触发。
  3. 检查工具是否返回受信任 terminal failure。
  4. 确认没有 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

stateDiagram-v2 [*] --> running running --> awaitingApproval: tool requests approval awaitingApproval --> running: approved, resume execution awaitingApproval --> toolResult: denied toolResult --> terminalHandling

产品级流程:

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 的成熟标志不是"模型能连续调用多个工具",而是系统能够明确说明:

  1. 当前任务进行到哪一步,为什么继续或停止?
  2. 工具失败、用户取消、审批等待和预算耗尽分别意味着什么?
  3. 用户新要求会在什么边界生效?
  4. 是否能证明终态只发生一次?
  5. 是否能定位 token、模型、工具、审批和持久化中的性能问题?

一句话总结:

好的 Loop Engineering 不只是让 Agent 能够多走几步,而是让每一步都可预算、可观察、可暂停、可恢复并安全结束。

继续阅读

相关推荐
未来智慧谷1 小时前
从 Atlas 关停复盘:AI 应用的三种载体形态怎么选(独立应用 / 浏览器扩展 / 桌面宿主)
前端·人工智能·ai·架构·浏览器
dogstarhuang1 小时前
Kimi K3 本地部署实战:从 1.56TB 权重到推理服务的完整成本分析
java·人工智能·后端·ai·开源·接口·程序员创富
去伪存真20251 小时前
数字工厂与产线孪生的平台能力深度拆解
大数据·运维·人工智能·机器人
人工智能培训1 小时前
中小企业低成本落地AI工具实操方案
大数据·人工智能·gpt·机器学习·agi
胡耀超1 小时前
AI出事后,怎么查?——读《AI Forensics》
人工智能·python·数字取证·ai取证·
sugar__salt1 小时前
前端路由技术详解:从传统多页到 React SPA 的完整进化之路
前端·javascript·react.js·前端框架
云端漫步19871 小时前
HarmonyOS NEXT AI 智能生活助手:设置中心开发
人工智能·华为·生活·harmonyos
崖边看雾1 小时前
机器学习——支持向量机
人工智能·机器学习·支持向量机
l0001091 小时前
康养地产适老化智能改造:安全防护体系构建路径与方案选型
人工智能·物联网·安全