第 13 周 · Tether 源码课
Agent Loop:让 Turn 与 Step 正确闭合
学完你能做到
- 区分 Agent driver、turn、step 与 request retry loop
- 按源码写出无工具和有工具两种事件时间线
- 根据 tool call、inbox、policy、error 与 cancellation 判断继续或停止
- 用 AgentLifecycleTests 验证并发隔离、durable inbox 与闭合事件
课程进度
- 第 1 周
- 第 2 周
- 第 3 周
- 第 4 周
- 第 5 周
- 第 6 周
- 第 7 周
- 第 8 周
- 第 9 周
- 第 10 周
- 第 11 周
- 第 12 周
- 第 13 周
- 第 14 周
- 第 15 周
- 第 16 周
一句话先懂
Agent loop 是一个“直到没有欠处理工作才停”的状态机:一个 turn 认领一批输入,一个 step 完成一次模型请求及其工具结果。
模型返回普通文字时可以结束;返回 tool call 时先执行并记录结果,再开下一个 step;无论成功、取消还是失败,finally 都负责闭合持久边界。
前四周分别拆了 Session、Projection、LLM 与 Tools。Agent loop 不重新实现它们,而是拥有“下一步是什么”的控制权,并保证每次交接都留下正确事实。
先看一条带工具的时间线
一个类比:办事窗口的一张工单
把 turn 想成窗口接到的一张工单,把 step 想成工作人员的一轮判断:
- 看当前材料,决定能否直接答复。
- 若需要档案,发出 tool call,先登记取档,再等待结果。
- 档案回来后仍是同一张工单,但工作人员要进行下一轮判断。
- 没有欠缺材料且没有新指示,才在工单上写结束原因。
Inbox 中新的 followup 可能成为下一张工单;steer / inject 则可进入当前 turn 的下一 step。
类比边界:Agent 支持异步 stream、并行 tools、cancellation、retry、scope-filtered pipeline 与 durable replay。窗口类比只表达 turn / step 的层次,不能推出实际并发或错误处理。
三层循环不要揉成一层
src/Tether.Core.AgentLoop/Agent.cs 可以按三个循环读:
DriveAsync
while TurnAsync() says inbox still has work
TurnAsync
while this turn still owes a step
PreStepAsync → step/start → StepAsync → step/end
StepAsync
while request-error policy says Retry
assemble request → llm stream → commit assistant
if tool calls: execute batch and return “continue turn”
else: return Completed
request retry 不创建新 step:同一 model request 在提交 assistant message 前重试。tool result 则必须让下一次 request 看见新增的 Session facts,所以会闭合当前 step 后再开一个 step。
一次 Step 做什么
按调用目的阅读,而不是从 500 行方法开头逐字符走:
- 从 Context
Require当前ILlmClient、IToolRegistry、ISystemPromptProvider。 - 解析本 Agent 的 provider/model;默认选择对这个 Agent 冻结,不在运行中漂移。
- 取得当前 scope 可见 tool declarations,组装 system prompt。
- 运行
agent/requestwaterfall,提交能重建本次 request 的request/header(未变时可省略)。 - 用
CaptureRequestMessages()从Session.SnapshotSurface()的同一个快照取得请求历史,提交request/start标记本尝试边界,运行llm/streamwaterfall 拿到流。 - 消费流:每个 provider 无关分片落成 durable
assistant/chunk,同时聚合 text / reasoning / function calls;流结束后 append 完整assistant/message,用SourceEventSeqs引用本尝试的 chunk。 - 没有 calls:返回 Completed candidate;有 calls:通过
SessionToolBatchSink执行并 append call/result。若某个 tool outcome 声明ConcludesTurn则返回 Concluded candidate,否则返回“还需下一 step”(null)让外层检查 inbox 后决定。
五个可介入的 Pipeline 文件
| 文件 | 介入点 | 可以做什么 |
|---|---|---|
AgentPipelinePreStep.cs | agent/pre-step | 接受、替换或拒绝刚认领的输入 |
AgentPipelineRequest.cs | agent/request | 调整 provider-neutral call config |
AgentPipelineStream.cs | llm/stream | wrapper / short-circuit exact client stream |
AgentPipelineRequestError.cs | agent/request-error | 对尚未提交 assistant 的失败选择 retry 等动作 |
AgentPipelineTurnStopping.cs | agent/turn-stopping | 本可结束前追加 next-step 工作 |
这些都是 scope-filtered typed APIs,内部借第 6 周的 Waterfall 或 Serial 实现。插件注册它们时仍由 EffectScope 回收。
哪些条件会改变“停下来”
| 条件 | 当前 step | turn reason / 后续 |
|---|---|---|
| 模型只返回文字,无 next-step inbox | 正常 step/end | completed,进入 stopping checkpoint 后关闭 |
| 模型返回 tool calls | 记录每个 call/result 后 step/end | 同一 turn 再开 step |
| steer / inject 留下 NextStep 消息 | 当前 step 结束 | 同一 turn 再开 step,并带入消息 |
| pre-step Reject | 不进入模型,可能没有 step/start | blocked |
| request failure 且 policy Retry | 尚未提交 assistant;先追加 request/failed 结算本次尝试,再经 agent/request-error waterfall 决定 | 同一 step 内重试 request |
| cancellation | finally 尝试写 step/end | aborted,SendAsync 传播 cancellation |
| 非取消 exception | finally 闭合边界 | error,记录 stable failure snapshot 后传播 |
| 超过配置的循环 bound | 追加 cutoff assistant message | 当前实现以 completed 闭合 |
turnEnds 已有值也不一定立即 break:agent/turn-stopping listener 或 NextStep inbox 可以让 turn 继续。这就是为什么“模型没有 tool call”只是结束候选,不是唯一停止条件。
finally 是持久语义,不只是防御写法
TurnAsync 在 step body 外层的 finally append step/end,在整个 turn 外层的 finally append turn/end。因此 tool exception、LLM failure 或 user cancellation 都会尽量留下平衡括号和 reason。
这不表示任何异常都能被伪装成成功:原 exception 仍传播给 SendAsync caller,runtime agent/error 也会报告非取消 failure。日志闭合与调用失败可以同时成立。
动手实验:为两种 turn 写事件序列
实验目标:先预测 plain text 与 tool round-trip 的持久事件相对顺序,再用 AgentLifecycleTests 核对 cancellation、retry 与 inbox。
1. 预测
为两个 script 分别写出至少这些 event 的顺序:
- plain:
text.json,只有hello-world; - tools:
tools.json,先echocall,再done。
允许省略 agent/inbox/spliced,但必须标出 turn/step 括号、user/assistant、request/header、tool/call 与 tool/result。回答:tools script 有几个 step、几个 turn?
2. 运行生命周期测试
dotnet test \
tests/Tether.Core.AgentLoop.Tests/Tether.Core.AgentLoop.Tests.csproj \
--filter FullyQualifiedName~AgentLifecycleTests
AgentLifecycleTests 确认:
- 两个 Agent 可并行请求而不共享 Session state;
- Followup / Steer / Inject 的 inbox mutation 可持久重放;
- cancellation 仍写出
turn/end的abortedreason; - tool/call 早于 tool/result;
- request-error listener 可以让一次 transient LLM failure 在同一 step retry。
3. 再验证最小 tool loop
dotnet test \
tests/Tether.Core.AgentLoop.Tests/Tether.Core.AgentLoop.Tests.csproj \
--filter FullyQualifiedName~Tool_calls_round_trip_until_a_text_answer
预期答案:tools script 仍是 1 个 turn、2 个 steps。第一 step 的 assistant 提议 call,ToolRegistry 把 result 追加到 Session;第二 step 的 request 因而以 Tool role message 结尾,模型再返回最终 text。
4. 解释
如果把 tool call 当作新 turn,用户的一次请求会被错误拆成多轮,取消、统计与恢复边界都会变化。如果在同一个 step 内悄悄重发加入 tool result 的 request,step 又不再代表“一次模型请求加其工具执行”。两层括号正是为了避免这种含混。
一份源码追踪清单
遇到 Agent loop 问题时,按现象选入口:
| 现象 | 先看哪里 | 再用什么测试 |
|---|---|---|
| 输入没有进入模型 | Inbox.cs、AgentPipelinePreStep.cs | InboxTests / PreStepTests |
| request 配置或 prompt 不对 | AgentPipelineRequest.cs、AgentPipelineIntegrationTests.cs | request / integration filters |
| stream 中断或 Provider 不匹配 | AgentPipelineStream.cs | AgentPipelineStreamTests |
| tool call 后没有下一 step | Agent.StepAsync、ToolRegistry | AgentLoopTests / ToolPipelineTests |
| cancellation 留下开口括号 | TurnAsync try/finally、Session invariant | AgentLifecycleTests / AgentResumeTests |
检查理解
1. 模型返回文字且没有 tool call,turn 是否一定立即结束?
查看答案
不一定。它先成为 Completed candidate;若 NextStep inbox 有 steer/inject,或 turn-stopping extension 追加了工作,同一 turn 会继续下一个 step。
2. LLM request 第一次失败,request-error policy 选择 Retry,会出现第二个 step/start 吗?
查看答案
不会仅因 retry 出现。Retry 在 StepAsync 的 request loop 内发生,尚未提交 assistant message,也尚未结束当前 step。只有该 step 完成且仍有工具/输入工作时才开下一 step。
3. cancellation 时写了 turn/end,为什么 SendAsync 仍应抛 OperationCanceledException?
查看答案
turn/end 是持久事实,说明这轮以 aborted 原因闭合;它不把调用结果改成成功。caller 仍需要知道请求未正常完成,二者服务不同观察者。
本单元带走
- Driver 排空 turns,turn 排空 steps,step 内可 retry 尚未提交的 request;三层循环边界不同。
- tool call、NextStep inbox 与 stopping extension 让 turn 继续;plain completion 只是停止候选。
- step/end 与 turn/end 在 finally 中闭合,错误和取消仍向 caller 可见。
你现在已经能从输入一路追到 durable result。下一单元把视角转向操作系统边界、动态加载和最终综合实践。完整生命周期参考可查核心与 Agent。