返回 AICAP-180
B3 · Day 24agent loop + 评测统计

无框架 agent loop 骨架

- 承接前三天:Day 21 判定了「该不该上 agent」并标出 agentEval.ts 缺一条真正的回路;Day 22 把工具契约定清;Day 23 补了评测统计。三块拼图齐了,今天动手把缺口补上。

阶段: B3 · agent loop + 评测统计(Day 21-30) 标签: #agent-loop #framework-free #dependency-injection #tool-calls

今日导引(由浅入深)

  • 承接前三天:Day 21 判定了「该不该上 agent」并标出 agentEval.ts 缺一条真正的回路;Day 22 把工具契约定清;Day 23 补了评测统计。三块拼图齐了,今天动手把缺口补上。
  • 今天做什么不依赖任何框架,手写一个 agent loop 状态机——observe→act→observe,硬上限 8 步。这是 B3 的核心构建物,也是整条 B1→B18 曲线上「agent,实现而非空谈」的那块基石。
  • 通向明天:明天(Day 25)就把它接到 AML 工具(typology + sarDraft)上解一个真实多步任务,Day 26 再挂进评测 harness 量化。
  • 最小可判定产出src/agent/loop.ts 的 framework-free 回路(已建、测试绿),离线 fixture 跑通一条 transcript 并 commit——真实模型驱动的 transcript 需 key。

1. 机理精读

为什么不依赖 LangChain / AutoGen,自己写状态机。 框架把回路、记忆、工具调度、prompt 拼装全包进黑箱,好处是上手快,代价是:

  1. 控制流藏在框架里,调试要钻框架源码,出问题难定位。
  2. 难做确定性单测——框架默认要真模型/真网络,无法零 key 验逻辑。
  3. 跟不上协议演进(tool-call 格式、MCP 更新),框架升级滞后。

自己写一个最小状态机,把「模型」和「工具」都做成注入依赖,就能用 fake model + fake tools 在零 API key、零网络下把回路逻辑测到底。这正是本仓 loop.ts 文件头写的「the model + tools are INJECTED, so the loop is fully unit-testable with a fake model and NO API key」。

状态机的动作序列。

  • 一个最小 agent loop 是:observe(看当前 transcript)→ LLM(带 tool schema)(模型决定下一步)→ 若返回 tool_call执行工具并把观测回填进 transcript → 再 LLM → ……直到模型给出最终答案或撞上限。
  • 把 Day 21 标的三处缺口对上:这里有了工具执行回填(缺口①)、step 状态机 / transcript 累积(缺口②)、终止与上限(缺口③)。
  • 三处缺口与 loop.ts 行号对账:①= loop.ts:51-52(tool.run + 观测推进 transcript);②= loop.ts:45-46(for step + model({task, transcript}));③= loop.ts:41,59(maxSteps=8 + hitCap:true)。

硬上限 = 失控保护。

  • agent loop 的根本风险是不收敛——模型反复调工具、来回兜圈、token 烧穿预算。
  • 对策是硬 step cap(本仓默认 8 步):到上限强制停,返回 hitCap: true 标记。
  • hitCap 让调用方知道「这次没在预算内完成」而不是悄悄死循环——评测时这是一类需要单独统计的失败模式(不同于「跑完但答错」)。
  • 这是把 Day 21「agent 成本不可控」这一权衡落到代码里的护栏。

依赖注入便于离线 fixture 测试。

  • model 做成纯函数 (ctx) => ModelStep、把 tools 做成 {name, run} 列表。
  • 测试时传一个脚本化的 fake model(按 transcript 长度返回预设的 tool_call 或最终答案)就能确定性地走完整条回路,无需真模型。
  • 真实运行时再把一个 LanguageModel-backed 的 step fn 注进来——把 OpenRouter 返回的 tool_calls 翻译成 ModelStep、把工具结果翻译回 transcript。
  • 协议侧参考 DeepSeek-V3 经 OpenRouter 的 function-call 协议(OpenAI 兼容 tool_calls 格式)。
  • 同栈对照:agentEval.tsmakeModelGenerate 已用 Vercel AI SDK 的 generateText 接 OpenRouter,真实 loop step fn 可复用同一接线方式。

为什么刻意做这么薄? 这一版 loop 只有 ~60 行、单文件、无依赖,是有意的:它把「agent 的最小可证伪内核」隔离出来——回路、工具回填、上限、失控标记,仅此而已。记忆、规划、多 agent、上下文压缩都不在这一层(属 B4 及之后)。薄到能一眼看懂、能确定性测,正是它作为「实现而非空谈」作品的价值所在;任何更复杂的 agent 都是在这个内核上叠层。

2. 推导 / 手算 / 代码走读

代码走读 src/agent/loop.ts(已建、测试绿)。该文件定义了五个类型 + 一个函数:LoopToolModelStepLoopTurnLoopModelLoopResult,以及核心 runAgentLoop。逐项:

  • 核心入口 runAgentLoop(opts)(loop.ts:34):参数 { task, model, tools?, maxSteps? },返回 LoopResult = {output, steps, toolCalls, transcript, hitCap}
  • 注入式模型 LoopModel = (ctx: {task, transcript}) => Promise<ModelStep> | ModelStep(loop.ts:24):每步把当前 task + transcript 喂给模型,模型回一个 ModelStep——要么 {tool: {name, args}}(要调工具),要么 {text}(最终答案)。这是「observe → decide」。
  • 工具表与上限(loop.ts:40-41):tools 注入为 LoopTool[] = {name, description?, run},进 Map 按名查;maxSteps = opts.maxSteps ?? 8——硬上限默认 8
  • 回路主体(loop.ts:45-58):for step=1..maxSteps:调 model → 若 out.tool 则把 call name(args) 推进 transcript、toolCalls++、查表执行 tool.run(args)(未知工具回 error: unknown tool "...")、把观测推进 transcript、continue(这是「act → 回填观测 → 再 observe」);否则把 out.text 当最终答案推进 transcript 并 return {..., hitCap:false}
  • 超限分支(loop.ts:59):循环正常走完都没拿到最终答案,返回 {output:'', steps:maxSteps, toolCalls, transcript, hitCap:true}——hitCap 显式标记失控。
  • 未知工具处理(loop.ts:51):tools.get(name) 取不到时回填 error: unknown tool "..." 作为观测,不抛异常——让模型读到错误后自我修正(呼应 Day 22 的结构化错误原则)。
  • 可测性:整个文件无 import、无网络、无时钟、无随机;model/tools 全注入,因此一个脚本化 fake model 就能确定性跑出整条 transcript(离线 fixture 即可 commit)。

把三处缺口对账:①工具执行回填 = loop.ts:51-52;②step 状态机 = for step + transcript 累积;③终止/上限 = maxSteps=8 + hitCap。Day 21 的缺口在此全部补齐

一条离线 fixture transcript 的手动走查(fake model 脚本化两步后给答案):

step1: model({task, transcript:[]})        → {tool:{name:'lookup', args:{...}}}
        push model: "call lookup({...})"     toolCalls=1
        push tool:  "<观测结果>"
step2: model({task, transcript:[2 turns]}) → {tool:{name:'assess', args:{...}}}
        push model: "call assess({...})"      toolCalls=2
        push tool:  "<观测结果>"
step3: model({task, transcript:[4 turns]}) → {text:"最终结论"}
        push model: "最终结论"
        return {output:"最终结论", steps:3, toolCalls:2, hitCap:false}

要点:steps=3(含最后给答案那步)、toolCalls=2(两次工具)、hitCap=false(没撞 8 步上限);整条轨迹由脚本化 fake model 确定性产生,零 key 可 commit

3. 今日实战

  1. Read src/agent/loop.ts,确认 runAgentLoop 的 observe→act→observe 结构、注入式 model/toolsmaxSteps=8 硬上限、hitCap 标记。
  2. 写一个脚本化 fake model + 一两个 fake tool,先解 1 个 AML 两步任务(如「查证据 → 给结论」)跑通一条 transcript。
  3. 构造一个「会撞上限」的退化用例:fake model 每步都返回 tool_call、永不给 text,验证回路在第 8 步停且返回 hitCap:trueoutput:''
  4. 构造一个「调未知工具」用例:fake model 返回 {tool:{name:'nope'}},验证观测回填 error: unknown tool "nope" 而非抛异常。
  5. 运行 pnpm test,验证 loop 单测绿(确定性、无 key)。
  6. 把离线 fixture 的 transcript commit;标注真实模型驱动的 transcript 需 key。

4. 今日实测 / 产出

  • 状态loop.ts 已建,测试绿(framework-free loop,injected model+tools,硬上限)。
  • 离线 fixture 即可跑通一条 transcript 并 commit。
  • 真实模型驱动的 transcript 需 key——待跑(需 OPENROUTER_API_KEY)。
  • 关键实现事实:maxSteps 默认 8;超限返回 hitCap:true;未知工具回 error: unknown tool "..."LoopResult 同时报 stepstoolCalls

5. 常见误区 / 陷阱

  • maxSteps 设太大或太小。 太小则正常任务被截断(假阴性),太大则失控代价高;8 步是本仓对当前 AML 三步任务的留余量选择,换任务族要重新校。
  • 忘了硬上限。 没有 maxSteps 的回路会死循环 / 步数膨胀,token 烧穿;本仓默认 8 步 + hitCap
  • 把模型/工具写死在 loop 里。 那样就无法离线测;必须依赖注入,才能用 fake model 确定性跑。
  • 未知工具不处理。 模型会调不存在的工具;要返回结构化错误(本仓回 error: unknown tool "...")让模型自我修正,而非崩溃。
  • 拿 AutoGen / Semantic Kernel 当主线。 二者处于维护模式,仅作教学对照;自建 framework-free 回路才是本计划主线。
  • stepstoolCalls 混为一谈。 steps 含最后给答案那一步,toolCalls 只数工具调用;一条 3 步轨迹通常是 2 次工具 + 1 次终答,二者不相等,报告时要分开看(Day 26 双指标的前提)。

6. 学习资源(每条带 YYYY-MM)

  • 本仓代码:src/agent/loop.tsrunAgentLoop / LoopModel / LoopTool / maxSteps=8 / hitCap)— 2026-06(本日构建物)
  • DeepSeek-V3 function-call docs(via OpenRouter,OpenAI 兼容 tool_calls 格式)— 2025-12(协议参考)
  • Anthropic, Building Effective Agents — 2024-12(agent loop 设计原则,Day 21 主线延续)
  • OpenRouter, Tool / Function Calling docs — 2026-06(真实模型驱动 transcript 的接线参考)
  • Vercel AI SDK, generateText / tools docs — 2026-06(真实 LanguageModel-backed step fn 的实现参考,与 agentEval.ts 同栈)

SOTA检查 (2026-06 更新)

  • 当前主流方案:framework-free + 依赖注入的自建 agent loop 是本计划主线(可测、可控、跟得上协议);DeepSeek-V3 经 OpenRouter 仍可用于真实驱动。
  • 是否仍 SOTA:是。但 DeepSeek-V3 / OpenRouter 的 version 须执行当周重验(agent 半衰期 ~6 月,模型迭代快)。
  • 过时黑名单避免把 AutoGen / Semantic Kernel 作主线(维护模式,仅作教学对照);勿引用过时的私有 tool-call 协议。
  • 补充对标:Claude 4.x Agent SDK / OpenAI o3 agents 是同期托管 agent 运行时;本仓自建薄回路的价值是「能讲清托管平台每个组件为何存在」(呼应 AIPA 作品③ build-vs-buy)。
  • 下次复查点:周一 WebSearch「DeepSeek function calling 2026」「OpenRouter tool calls 2026」;运行真实 transcript 当周确认模型可用性与版本号。

衔接

  • 昨天:Day 23 — 评测统计基础(点估计会骗人,Wald 95% CI)
  • 今天:手写 framework-free agent loop(observe→act→observe,硬上限 8 步,hitCap 失控标记)
  • 明天:Day 25 — 多步 AML 任务接入(把 loop 接到 typology + sarDraft 解三步任务)