LLM 面试备考
首页/Agent 架构

从 0 实现一个 Agent Loop:宏观步骤(Agent Loop from Scratch)

所属:01-fundamentals / 04-agent-architecture
面试权重:★★★(「如果让你从零搭一个 agent」是常驻开场题;答「while 循环 + 工具调用」是背的,能说出消息协议对齐、错误回灌、六个刹车、压缩的时机才是搭过的)
来源:《深入理解 AI Agent:设计原理与工程实践》第 1 章(Model–Harness 结构、Harness 五要素、五阶段演进)、第 2 章(消息四种角色与 tool_call_id 回灌、Agent 状态栏、分层压缩);循环终止条件引 ../../02-interview/design/07-single-agent.md §4.3。本篇是「跟着课程从 0 搭」的宏观步骤与自检口径,不是生产经验(边界声明见下)。
相关知识点:./agent-react.md(循环的概念层:判据、ReAct 定义、错误恢复清单 —— 本篇不复述)、./tool-use.md(工具描述与调用:schema 怎么写、错误怎么回灌)、./context-engineering.md(上下文组装:预算与压缩)、./14-agent-state.md(状态与恢复:本篇只讲「什么时候该把状态拆出来」)、./08-agent-stack.md(栈的分层:Tool / MCP / Skill / Harness)、../../02-interview/design/07-single-agent.md(§4.3 的六个刹车,直接引用不重写)、../06-agent-infra/11-streaming.md(流式)、../06-agent-infra/observability.md(观测)、../05-agent-evaluation/agent-eval.md(评测)、./16-agent-runtime-tools.md(框架与运行时选型:手写一遍才知道框架替你兜了什么)

一句话#

从 0 搭一个 Agent Loop,难点不在循环本身(那是一个 while),而在每一步的输出要被下一步正确地接住 —— 所以每一步都要问一句:我怎么知道这步做对了。

为什么重要(面试角度)#

  • 它把「背的」和「搭过的」分开。 面试官问「让你从零做一个 agent」,答「一个 while 循环加工具调用」谁都会。能接着说出消息协议的 id 对齐、工具错误要回灌成一条消息、退出条件要几个正交的刹车、压缩该等到有 token 数据之后再谈 —— 这几样只有真搭过一遍才会卡在那里,答出来就是分水岭。
  • 它是第 1 块「Agent Runtime 基础」的学习主线。 Loop / Tool Calling / Context / Streaming / Agent State / MCP / Multi-Agent 这几块,在仓库里是分开的篇(./agent-react.md、./tool-use.md、./context-engineering.md、../06-agent-infra/11-streaming.md、./14-agent-state.md),而从 0 搭一遍正好把它们串成一条时间线:先有单次调用,才有协议;有协议,才谈工具;有工具,才谈循环;有循环,才暴露上下文与状态问题。
  • 它给 ./16-agent-runtime-tools.md 的选型判断提供底。 你没手写过,就分不清框架哪一层在帮你、哪一层在骗你 —— 比如框架悄悄帮你对齐了 id、悄悄吞掉了工具异常,你以为自己会了,换一个框架或换一个模型就塌。手写过一遍,那张图上的每一格才有对应的体感。

⚠️ 边界声明(先立在前面):这一篇记的是我跟着课程从 0 搭 + 我自己的理解,不是生产经验。全文的口径统一是「我这一步的理解是 / 我会这么判 / 卡住的时候我会先怀疑这里」;凡是讲成「必须这样」「线上都这样」的地方,都是越界,请按「我会这么判」读。真正的手写范围是单机、单任务、跑得通也恢复得起来的最小实现,跨机器恢复、多写者并发、调度与隔离不在其中(那些见 ./14-agent-state.md 与 ../06-agent-infra/10-agent-runtime.md)。

核心内容#

八步,每一步用同一个小结构写:做什么 / 输入 → 输出 / 关键判断 / 怎么知道这步做对了 / 常见坑。最后给一张「八步 × 验收信号」速查表和一段收口。

顺序不是随便排的 —— 每一步的产物就是下一步的输入。 这也是我会坚持「先把单次调用跑通」的原因:只有先看清原始响应,才定得下协议;只有协议对,工具结果才接得住;只有工具通了,循环才是个有意义的循环;只有循环跑起来,上下文与状态的账才有得算。反过来,跳过某一步直接写下一步,问题会在最后一步集中爆出来,而且症状指向的地方是错的 —— 这是我认为最难排查的一类故障。

搭到什么程度算「搭完」(本文的范围):单机、单任务、跑得通也恢复得起来的最小实现 —— 一段能自己决定调不调工具、会停、能从明确的边界恢复的循环。不包含:多任务并发与调度、跨机器恢复、沙箱与隔离、多 Agent。这是我的诚实边界的一部分 —— 上面这几样我没有手写过,本篇不对它们下判断。

步骤 1:先把单次模型调用跑通(先不上工具)#

做什么:用最裸的 HTTP 请求或 SDK,发一次 system + user 两条消息的调用,不带工具,然后把返回的原始响应整体打印出来,而不是只把 content 字段抽出来看。

输入 → 输出:输入是一次「system + user」的请求;输出是一个完整的、未加修饰的响应对象 —— 后面所有步骤都以它的字段为准。

关键判断:这一步我要看清两样东西 —— ① 工具调用字段长什么样(tool_calls / tool_use 里的 name、arguments、id 三个字段);② 结束原因是什么(正常收尾、请求调工具、被长度截断、被内容过滤)。这两样是后面所有调试的坐标系;没有它,后面出问题只能靠猜。

怎么知道这步做对了:能拿到一次完整响应并打印出它的全部字段 —— 至少能看到 id、role、content、tool_calls(即使为空也要能看到这个字段存在)、finish_reason、usage(输入/输出 token 数)。

常见坑:一上来就用高层封装(框架里的 Agent / Chain 抽象),把这一层结构整个盖住。等后面遇到 id 对不上、被截断、模型「假装」调了工具时,所有调试都变成猜。另一个坑是只看 content、不看 finish_reason 与 usage —— 于是「为什么它没调用工具」「这一轮花了多少 token」这两问永远答不出来,预算刹车也就无从设。

步骤 2:定消息协议#

做什么:定下发起消息的三类角色 —— system(开发者的规则与身份)、user(用户输入)、assistant(模型的历史回复,包括它发过的工具调用请求);再定工具结果怎么回灌:在多数 API 里它是独立的一条 tool 消息,靠 id 与对应的调用请求关联。(这是第 2 章的「四种消息角色 + tools 字段」口径 —— 工具定义走请求顶层的 tools,不是一种消息角色。)

输入 → 输出:输入是「三类角色 + 工具结果」的排布约定;输出是一份能让模型正确接住工具结果的 messages 结构。

关键判断:工具结果的 id 必须与模型发出的调用 id 一一对齐;并且那条含工具调用的 assistant 消息要原样放回消息列表,否则模型看不到自己之前做过什么决策。

怎么知道这步做对了:构造一轮假的工具往返 —— 手工造一条带 id 的 assistant 工具调用,再补一条同 id 的工具结果,看模型能不能正确接住这个结果、基于它继续或收尾,而不是抱怨缺参数、或者把结果张冠李戴。

常见坑(这一步最容易做错,而且症状最迷惑):id 对不上时模型不会报错 —— 它会「假装」自己已经调过工具,直接编一个结果继续往下走。表现是「看起来能跑通,但答案是编的」,非常难发现。所以验收必须用可控的假往返主动构造,而不是等真工具接上、结果碰巧对了就以为协议对了。另一个坑是回灌时把工具结果塞进 user 消息、格式也不对齐 —— 有的模型能忍,换个模型就崩。

步骤 3:定工具#

做什么:每个工具 = 一份 JSON Schema(name + description + parameters)+ 一个执行器,注册成请求顶层的 tools。参数由模型生成,校验和执行都在你这侧。

输入 → 输出:输入是每个工具的一份 schema;输出是一张「名字 → 执行器」的注册表,外加一条约定:错误也走结果通道。

关键判断:工具执行失败要回灌成一条工具结果消息,而不是抛异常把循环打断。原因是「失败对模型是信息,对程序是控制流」—— 把错误以一条结构化、可读的结果送回模型,它下一轮才能改参数重试;而抛异常会直接终止整个循环,把一个可自纠的小错变成一次崩溃。工具描述写得好不好,直接决定模型选不选它、参数填得对不对 —— 这块的写法全在 ./tool-use.md,本篇不重复。

怎么知道这步做对了:故意让工具报错(传一个非法参数、或让执行器主动抛错),看模型能不能读到错误、换一种做法继续,而不是整个循环挂掉或原地打转。

常见坑:把执行器的异常往外抛导致循环终止;错误信息给一坨栈(模型改不动,要的是「哪个字段、为什么、收到了什么」);schema 的 description 只写一句「查询数据」(描述就是 prompt,见 ./tool-use.md);把有副作用的工具也放进允许并行的集合里。

步骤 4:循环骨架#

做什么:写那个 while —— 调模型 → 有工具调用就执行并回灌结果 → 再调模型,直到满足退出条件。

输入 → 输出:输入是「调模型」和「执行工具」这两个动作;输出是一个会停的循环 —— 含正常出口与多个刹车。

关键判断:退出条件必须是显式的、而且不是一个而是多个正交的刹车。具体那六个(最大步数、预算上限、成功判据、无进展检测、连续失败熔断、超时)在 ../../02-interview/design/07-single-agent.md §4.3 有完整的一张表,这里直接引用,不重写。我要补的一句是:除了「刹车」,还有两个正常出口 —— 模型不再发起工具调用(直接给出最终回答),或通过一个「最终输出工具」显式声明任务完成。刹车是异常兜底,正常出口才是主路径,两者要分开。

怎么知道这步做对了:能人为触发每一个刹车,并看到它停下 —— 尤其「无进展检测」和「超时」这两个不报错的,最容易被漏(模型可以永远有工具调用,看起来一切正常)。

常见坑:只写「没有工具调用就退出」这一个条件,等于没有刹车;预算只管步数不管 token(同样步数成本可以差一个量级);终止之后不设计返回什么(部分结果 / 澄清追问 / 转人工 / 明确报错)—— 这个决策就是产品设计,不设计等于把失败直接甩给用户。

步骤 5:上下文管理#

做什么:明确每一轮往消息列表里放什么 —— 系统指令、工具定义(在请求顶层 tools,不在消息列表里)、对话历史、工具结果。

输入 → 输出:输入是每轮要放进去的各类内容;输出是一张每轮 token 构成的分解表 —— 压缩的依据。

关键判断:这一步先别做压缩。 我会先把「每轮多少 token、哪一部分占得最多」测出来,再决定压什么。理由是压缩是有损且不可逆的,在不知道自己被什么撑满之前动手,等于本末倒置:大概率压错了对象,还把早期约束压没了。把上下文当预算算账、以及压缩该怎么做,都在 ./context-engineering.md,本篇只讲时机。

怎么知道这步做对了:能量出每轮的 token 构成 —— 系统指令多少、工具定义多少、历史多少、工具结果多少、给输出预留了多少。有了这张分解表,「该压谁」就是一个有数据支撑的判断。

常见坑:还没量就上滚动摘要或滑窗截断;把工具定义当「免费的」(它是每轮常驻的上下文成本,几十个工具就是每轮上万 token);把动态内容插到上下文最前面,反复击穿 prompt caching 的稳定前缀。

步骤 6:流式#

做什么:把模型输出和工具执行都改成增量 —— token 一到就渲染;工具参数一边到达一边尝试解析,不必等整轮说完。

输入 → 输出:输入是整块的模型输出与工具执行;输出是两路增量 —— 用户侧的首字流,和运行时侧可以提前启动的工具执行。

关键判断:两个非显然的点。① 流式改善的是首字延迟的体感,不改变端到端总时长 —— 总 token 数没变、TPOT 没变,它把「等 8 秒」变成「立刻有字」。② 首个完整工具调用一到位就可以开始执行,不必等这一轮其余调用都生成完 —— 这一条才真的压总时长(工具执行与后续生成重叠)。两个「流式」压的不是同一项,答题时务必分开说;完整的机制、三层与失败模式见 ../06-agent-infra/11-streaming.md,本篇不重复它的论述。

怎么知道这步做对了:能看到第一段输出到屏幕的时间(TTFT)明显早于整轮结束;并且从日志里能看到工具在轮次结束之前就已经开始跑了(工具启动时间早于模型 finish)。

常见坑:把「流式」当成「更快」讲;解析器一遇到半截 JSON 就抛异常(半截不等于畸形,要能区分);已经因流式提前执行的工具,在续写或重试时没有按调用指纹去重,副作用做了两遍。

步骤 7:状态与持久化#

做什么:到这一步再决定「状态拆不拆出来、在哪个边界落盘、恢复时从哪续」。顺序是 先能跑,再能恢复 —— 不要在第 1 步就上 checkpoint。

输入 → 输出:输入是循环里那些「不能被压掉」的东西(目标、约束、进度、待办);输出是一份能重建循环的状态 + 一个明确的恢复边界。

关键判断:消息列表 ≠ 任务状态。 消息(对话历史)可以摘要、可以截断;而任务状态 —— 目标与约束、做过哪几步、待办 —— 一旦被压缩策略无差别地压掉,agent 就丢目标(表现为重复已完成的工作、偏离原始任务)。所以我会在这里先问一句:这份循环到底靠什么记住「我在干什么」,它是不是被混在消息列表里了。拆不拆、schema 怎么定、恢复语义怎么定义,全在 ./14-agent-state.md,本篇只负责指出该在这里停下来做这个判断。

怎么知道这步做对了:杀掉进程再拉起来,能从明确的边界继续 —— 恢复位置是清楚的(从哪一个「完整」的 checkpoint),并且从那个边界往后重做的那一段是幂等的(重做不会产生重复副作用)。

常见坑:认为「消息列表就是状态」,把两者混在一个不断变长的列表里;落盘只写了本地页缓存就当成「存好了」(write() 返回 ≠ 落盘);恢复时对不确定的操作直接重试,而不是「先查询、再决定」。

步骤 8:观测与评测#

做什么:定下每轮记什么 —— 这一轮的输入、模型的决策(调了哪个工具、什么参数、为什么)、工具调用与结果、耗时、token 数。

输入 → 输出:输入是每一轮的运行事实(输入、决策、工具、耗时、token);输出是一条能定位「从哪一步开始偏」的轨迹。

关键判断:断言「状态迁移」,而不是只看最终输出。 因为最终输出对了也可能是绕路绕对的 —— 中间调错工具、多烧三倍 token、跳过该走的确认,最后碰巧也能得到对的答案。只看终点的评测会把这几类全记成「成功」。观测该记什么见 ../06-agent-infra/observability.md,轨迹该怎么评见 ../05-agent-evaluation/agent-eval.md。

怎么知道这步做对了:一次失败的运行,能在日志里定位到「是哪一步开始偏的」 —— 而不是只看到「最后失败了」。

常见坑:只记最终答案;埋点字段没对应上将来要回答的问题(当时没记,事后就捞不出来);成功 case 也全量存导致成本失控(失败全记、成功按比例采样)。

速查:八步 × 验收信号#

# 步骤 一句话做什么 验收信号(我拿什么判定这步做对了)
1 单次调用 裸调一次,不带工具,看清原始响应 能打印出完整响应的全部字段(含 tool_calls、finish_reason、usage)
2 消息协议 定三类角色 + 工具结果回灌 构造一轮假工具往返,模型能正确接住结果
3 定工具 JSON Schema + 执行器 故意让工具报错,模型能读到错误并换一种做法
4 循环骨架 一个 while + 显式退出条件 能人为触发每一个刹车并看到它停下(尤其无进展、超时)
5 上下文管理 定每轮放什么;先不压缩 能量出每轮的 token 构成
6 流式 输出与工具执行改增量 第一段输出到屏的时间可测;工具在轮次结束前就开始跑
7 状态与持久化 拆状态、定边界、定恢复语义 杀进程再拉起,能从明确的边界继续且重做幂等
8 观测与评测 每轮记输入/决策/结果/耗时/token 一次失败能在日志里定位到从哪一步偏起

这张表的用法:它是给自己的实现做自检的清单,不是面试背诵的答案。任何一步说不出「我怎么知道它对了」,基本就是还没真做对 —— 尤其是第 2、4、7 步,它们的错误不报错,只有主动构造才能验出来。

卡住的时候,我会按这个顺序怀疑#

排障的顺序反过来就是搭建的顺序 —— 从最底层开始怀疑,因为下游的症状往往是上游的错,而症状指向的那一步常常是无辜的。

你看到的症状 我会先去怀疑 为什么是这里
结果是编的,但看着跑通了 步骤 2 的 id 对齐 id 对不上时模型不报错,它会假装调过工具、编一个结果
模型不调工具、只空谈 步骤 3 的工具描述、步骤 1 的 finish_reason 描述没写清「何时用」,或者工具压根没进请求
同一个工具反复调 步骤 4 的无进展检测、步骤 3 的错误信息可读性 要么缺这个刹车,要么模型读不懂错误只能瞎试
越到后面越不守规矩 步骤 5 的上下文构成 早期约束被长工具输出挤掉了,不是模型「变笨了」
明明没做完却说做完了 步骤 7 的任务状态 状态被压缩策略顺带压掉,agent 丢了目标
重启后重复做了一遍 步骤 7 的恢复语义 没做「先查询、再决定」,或重做那一段不幂等
一次失败定位不到是哪步坏的 步骤 8 的埋点 缺的是每步的输入/决策/结果,不是「日志太少」

一句话:从下往上查,别从症状所在的那一步查。 这也是第 1 步「看清原始响应」值得多花半小时的原因 —— 它是整张排查表的坐标系,没它就只能在症状附近打转。

手写一遍之后你才知道的事(收口)#

框架替你兜的,正好是 Harness 五要素里的后三项加状态与恢复。 第 1 章的口径是 Harness = 上下文管理 + 工具接口 + 约束 + 验证 + 纠正,并给了一个很硬的判断:以 Claude Code 为例,它的 Harness 中绝大部分代码都是约束、验证与纠正,而非上下文与工具 —— 工具本身只是一小部分。手写一遍之后,这句话就从「一句正确的断言」变成了能对号入座的东西:

  • 约束 —— 对应我第 3、4 步里那些「默认关掉、要显式开放」的边界(工具权限、可并行的白名单、六个刹车)。
  • 验证 —— 对应第 8 步:断言状态迁移、看结构化字段,而不是看模型自由生成的文本。
  • 纠正 —— 对应第 3 步的错误回灌与第 4 步的熔断:失败先自己收拾,收拾不了才外抛。
  • 状态与恢复 —— 对应第 7 步:这些框架通常都替你做了,而你没写过就不知道它在哪一层做、做到什么程度。

这也正是为什么框架能骗人:它会悄悄帮你对齐 id、悄悄吞掉工具异常、悄悄给你一个默认的 max_iterations。demo 因此跑得很顺,但你不清楚哪一层是你写的、哪一层是它兜的 —— 换个模型、换个框架、或者任务一变,问题就从被兜住的地方冒出来。分不分开,取决于你要的是一张选型图还是一堆 API(见 ./16-agent-runtime-tools.md)。

放到更长的时间尺度上看,第 1 章把这条线排成五阶段:提示工程 ⊂ 上下文工程 ⊂ Harness 工程 ⊂ Loop 工程 ⊂ Graph 工程 —— 我手写的这八步,正好是从「上下文工程」走到「Loop 工程」的那一段。所以它的价值不在于你会写一个循环,而在于它让你知道后面每一层是往哪加的、以及不加会缺什么。

选型判据一句话:抽象层越少越好 —— 因为你能手写出来的那一层,就不该交给框架替你猜;框架的价值在它替你把约束 / 验证 / 纠正 / 状态恢复做扎实,而不是替你藏起循环(该论点与框架生态的完整对照,交 ./16-agent-runtime-tools.md 与 ./15-orchestration.md 展开,本篇只给判据)。

那我还用不用框架?—— 用手写这一步来校准你对框架的判断。 手写的目的不是「以后都手写」,而是拿到一把尺子:知道 id 对齐、错误回灌、退出条件、状态恢复各占多少工作量,再回头看框架把哪几样做掉了、哪几样只是换个名字包了一层。做完这一遍,我判断「这个框架值不值得上」的顺序大概是 —— 先看它替你兜的那几样是不是你真需要(要不要跨重启、要不要 HITL、谁来运维),再看它藏起来的那个循环会不会挡住你调试。所以要不要手写一遍,取决于你想要的是「一个能跑的 demo」还是「一把能用来选型的尺子」。

常见面试问法#

Q:如果让你从零实现一个 Agent Loop,你会按什么顺序做? A:分八步,每一步都先问「我怎么知道这步做对了」。① 先把单次模型调用跑通、不带工具,目的是看清原始响应的结构(工具调用字段、结束原因),别一上来就用封装把它盖住;② 定消息协议 —— 三类角色加工具结果回灌,关键是工具结果的 id 必须和模型发出的调用 id 对齐;③ 定工具 —— 每工具一份 JSON Schema 加一个执行器,工具报错要回灌成一条工具结果消息,而不是抛异常打断循环;④ 循环骨架 —— 一个 while,退出条件要显式;⑤ 上下文管理 —— 先不压缩,先把每轮 token 构成量出来;⑥ 流式 —— 输出与工具执行改增量;⑦ 状态与持久化 —— 先能跑,再能恢复;⑧ 观测与评测 —— 断言状态迁移而不只看最终输出。

Q:循环的退出条件有哪些? A:主要靠正常出口加多个正交的刹车。正常出口有两个:模型不再发起工具调用、直接给出最终回答;或者通过一个「最终输出工具」显式声明完成。刹车是异常兜底,../../02-interview/design/07-single-agent.md §4.3 给了六个正交的刹车(最大步数、预算上限、成功判据、无进展检测、连续失败熔断、超时),这里不重复它的表。要点有两个:刹车不能只有一个 —— 「没有工具调用就退出」等于没有刹车,因为模型可以永远有工具调用;以及终止之后返回什么本身是产品设计(部分结果 / 澄清 / 转人工 / 报错),不设计就等于把失败甩给用户。

Q:工具调用失败时,为什么不能直接抛异常? A:因为**「失败」对模型是信息,对程序是控制流**。把错误作为一条结构化、可读的工具结果消息回灌给模型,它下一轮就能改参数、换工具、或换个策略;而抛异常会直接终止循环,把一个本可自纠的小错升级成一次崩溃。所以正确做法是:错误变成模型的输入,而不是绕过模型的控制信号。这也是 Harness 五要素里「纠正」这一项最具体的落点。

Q:你怎么验证自己的消息协议是对的? A:主动构造一轮假的工具往返:手工造一条带 id 的 assistant 工具调用,再补一条同 id 的工具结果,看模型能否正确接住。不能只等真工具接上就宣布协议对了 —— 因为 id 对不上时模型不会报错,它会「假装」自己调过工具、直接编一个结果继续往下走,症状是「看着能跑,答案是编的」。这个坑是整条链路里最难发现的一个,所以必须靠可控的假往返来验。

Q:上下文快满了,你会先做什么? A:先不做压缩,先把每轮的 token 构成量出来 —— 系统指令、工具定义、历史、工具结果各占多少,输出预留了多少。理由有两个:压缩有损且不可逆,在不知道自己被什么撑满之前动手容易压错对象;而且工具定义是每轮常驻成本,几十个工具就是每轮上万 token,这一块经常才是真正的元凶,而它根本不该靠「压缩历史」去解决(该改的是工具披露或做工具检索)。等有了数据,再按「先裁工具输出 → 再摘要历史 → 最后才截断」的顺序动手,并且压缩策略要配评测。

易错点#

易错说法 问题 应该说
「Agent Loop 就是一个 while 加工具调用」 把循环的骨架当成了全部难点 循环本身简单,难点在每一步的输出要被下一步正确接住 —— 所以每一步都要问「我怎么知道这步做对了」
「一上来就用框架搭最快」 封装把响应结构盖住了 先裸调一次、看清原始响应(工具调用字段、结束原因、usage),否则后面所有调试都在猜
「工具结果的 id 随便填 / 不用管对齐」 触发最迷惑的一类失败 id 必须与调用 id 一一对齐;对不上时模型不报错,而是假装调过工具、编一个结果继续走
「工具报错就抛异常终止循环」 混淆了信息与控制流 错误要回灌成一条结构化工具结果消息(失败对模型是信息),而不是抛异常把循环打断
「退出条件就写『没有工具调用就退出』」 等于没有刹车 刹车要多个正交(§4.3 的六个),且「没有工具调用」只是正常出口,不是兜底
「无进展检测和超时可以后面再补」 这两类失败不报错 它们最容易被漏(模型可以永远有工具调用、连接可以静默卡死),要主动构造触发来验收
「预算上限就是限制步数」 步数不等于成本 同一步数成本可以差一个量级;预算要看 token / 金额,比步数更贴近真实成本
「上下文满了就上滚动摘要」 压得太早、压错对象 先量出每轮 token 构成再压;压缩有损且不可逆,过早压缩是本末倒置
「工具定义是免费的」 漏掉常驻成本 工具定义每轮都占上下文;几十个工具就是每轮上万 token,这是「工具该拆多细」的依据
「流式让 agent 更快」 把体感当成了时延 流式改的是首字延迟的体感,端到端总时长不变;真正压时长的是首个完整工具调用即可执行
「消息列表就是任务状态」 两者可丢弃性完全不同 消息可以摘要截断,任务状态被压掉 agent 就丢目标;两者要能物理分开
「恢复就是重试没做完的那一步」 混淆「没做完」与「做完了但确认丢了」 不确定的操作要「先查询、再决定」,且重做那一段必须幂等
「最终输出对了就说明这次运行没问题」 只看终点掩盖了过程问题 要断言状态迁移 —— 绕路成功、侥幸成功、违规成功都会在终点看起来「对」
「先接工具再跑通模型调用」 顺序反了 先跑通单次调用看清响应结构,再定协议,最后才接工具 —— 协议没定就上工具,错误会指向错的地方
「框架都帮我做了,所以不必懂这些」 分不清哪一层是谁做的 手写一遍是为了拿到尺子:框架会悄悄兜住 id 对齐、工具异常、默认轮数上限,你没写过就不知道它兜到哪
「这八步是线上的标准流程」 越界 这是跟着课程从 0 搭的最小实现顺序,不是生产经验;口径是「我会这么判」

自测#

1. 为什么「消息协议的 id 对齐」是最难发现的一个坑?你会怎么验收它?

答案

难发现,因为它的失败不报错。当工具结果的 tool_call_id 与模型发出的调用 id 对不上时,模型不会抛错、也不会提示「结果缺失」,而是会**「假装」自己已经调过工具、直接编一个结果继续往下走** —— 整个循环看起来跑得很顺,只是答案是编的。这类「看着能跑、结果是假的」问题,靠观察现象几乎发现不了,必须主动构造才能验出来。

验收方式:构造一轮假的工具往返 —— 手工造一条带 id 的 assistant 工具调用,再补一条同 id 的工具结果,看模型能否正确接住这个结果(基于它继续或收尾),而不是抱怨缺参数、重复调用、或把结果张冠李戴。另外注意一点:含工具调用的那条 assistant 消息要原样放回消息列表,否则模型看不到自己之前做过什么决策。

2. 工具执行失败了,为什么不能直接抛异常终止循环?正确的做法是什么?

答案

因为**「失败」对模型是信息,对程序才是控制流**。抛异常会把一个可能自纠的小错(参数写错、临时限流)升级成一次整体崩溃;而把错误作为一条工具结果消息回灌,模型下一轮才有机会改参数、换工具或换策略。

正确做法:错误以普通工具结果的身份进入上下文,并且要结构化、可读 —— 说清是哪个字段、为什么错、收到了什么(例如「date 必须是 YYYY-MM-DD,收到的是 2024/1/5」),而不是一坨异常栈。同时区分可重试(超时、限流)与不可重试(参数非法、权限不足),后者更适合让模型改参数而不是盲目重试。这也是 Harness 五要素里「纠正」的落点:失败先自己收拾,收拾不了才外抛。

3. 上下文快满了,为什么说「先别压缩」?你会按什么顺序处理?

答案

因为压缩有损且不可逆,在不知道自己被什么撑满之前动手,很可能压错对象,还把早期设定的约束一起压没了 —— 这是本末倒置。所以我第一步不是压,而是量:把每轮的 token 构成(系统指令、工具定义、历史、工具结果、输出预留)测出来。

量出来之后,常见顺序是:① 先裁工具输出(在工具层截断、分页、只取字段)—— 这一块通常噪声最大、性价比最高;② 再考虑对历史做滚动或结构化摘要(保留决策、约束、待办,丢掉过程);③ 更根本的一招是子 agent 隔离,把污染性强的工作放到子 agent,只回传结论;④ 最后才是截断 / 滑窗。还有一个很可能被忽略的点:如果占得最多的是工具定义(几十个工具就是每轮上万 token 的常驻成本),那它根本不该靠压缩历史解决 —— 该改的是工具披露或做工具检索。

4. 为什么「只看最终输出」不足以判断一个 agent 跑得好不好?你会断言什么?

答案

因为最终输出对了,也可能是绕路绕对的。中间调错工具、多烧三倍 token、跳过该走的确认,最后碰巧也能得到对的答案;只看终点的评测会把「侥幸成功」「绕路成功」「违规成功」这几类全记成成功,也就丢掉了改进方向。

我会断言的:① 状态迁移 —— 期望的中间状态序列(如「待办非空时不得进入 done」)是否被遵守,而不只是终点对;② 每一步的决策与结果 —— 调了哪个工具、什么参数、耗时与 token,用来归因「是从哪一步开始偏的」;③ 对不该动手的场景专门构造用例,把「过度行动」当一个指标。这也就是为什么第 8 步的验收信号是「一次失败的运行能在日志里定位到是从哪一步开始偏的」,而不是「最后是不是对的」。