1. 从一次裸调用说起:为什么单次 LLM 调用撑不起一个 Agent
很多人第一次接触 Agent,脑子里想的其实很简单:不就是把用户输入拼进 prompt,丢给大模型,拿到回复再返回给用户吗?这个理解在 Demo 阶段完全够用,但只要你想让它真正干活——比如读文件、查数据库、调接口、多轮修正——你会发现单次调用这条路走不了几步就崩了。
我最早做的一个小工具就是这种"裸调用"结构:用户问一句,我拼一段 prompt,调一次模型,把结果打印出来。前两周用着挺爽,第三周开始出问题。用户问"帮我看看这个报错日志里哪一行是根因",模型说"我需要看到日志内容",但我的代码里根本没有把日志塞进去的机制,因为上一轮对话里用户只是提了一嘴"日志在某个路径下"。模型没有工具去读文件,也没有记忆去记住那个路径,更没有循环去"先读再判断再回答"。这就是单次调用的天花板:它只能做一次映射,做不了多步决策。
Agent 的本质区别在于,它把"一次映射"变成了"一个循环"。这个循环里至少要有四样东西:模型推理、工具执行、状态记忆、循环控制。少了任何一样,它就不是 Agent,只是一个带格式的文本生成器。而把这四样东西组织起来的那层代码,就是标题里说的 Harness Runtime——你可以把它理解成 Agent 的"操作系统",模型是 CPU,工具是外设,Runtime 负责调度、传参、兜底和终止。
所以这篇文章想解决的问题很具体:当你决定从一次 LLM 调用升级到一个完整 Agent 时,架构该怎么选、每一层要做什么、哪些坑必须提前避开。我会按"从简到繁"的顺序,把几种主流架构拆开讲,每一种都告诉你它适合什么场景、代价是什么、我实际踩过哪些坑。不管你是刚写完第一个 Demo 想往上加功能,还是已经在维护一个多工具 Agent 想重构,应该都能找到能直接抄的部分。
2. Agent 架构的四层拆解与选型逻辑
2.1 先搞清楚 Agent 到底由哪几层组成
在选架构之前,得先把 Agent 的组成拆清楚。我习惯把它分成四层,从下往上依次是:
- 模型层:负责推理和决策,输出"下一步做什么"。它可以是单模型,也可以是多模型分工(一个规划、一个执行、一个校验)。
- 工具层:模型能调用的所有外部能力,包括读文件、发请求、查库、执行代码等。工具的定义质量直接决定 Agent 的上限。
- 状态层:保存对话历史、中间结果、工具返回值、任务进度。没有状态层,Agent 就是金鱼记忆,每轮都从零开始。
- 控制层(Harness Runtime):决定循环怎么转、什么时候停、出错怎么办、多个工具怎么串。这是最容易被低估、也最容易写乱的一层。
这四层不是必须物理分离,小项目里它们可能全挤在一个文件里。但逻辑上必须清晰,否则你会在加第三个工具的时候发现代码已经没法维护了。我见过太多项目,工具调用逻辑和 prompt 拼接逻辑混在一起,改一个工具要动五处代码,最后没人敢动。
选架构的本质,就是决定这四层怎么组织、耦合到什么程度。下面几种方案,基本覆盖了从个人项目到生产系统的常见选择。
2.2 四种主流架构的适用场景对比
我把常见的 Agent 架构归成四类,按复杂度递增排列。先给一张对比表,后面逐个展开。
| 架构类型 | 核心特征 | 适合场景 | 主要代价 |
|---|---|---|---|
| 单轮工具调用 | 一次推理+一次工具执行 | 简单查询、单步操作 | 无法处理多步任务 |
| ReAct 循环 | 推理-行动-观察循环 | 中等复杂度、工具少于10个 | 容易死循环、token 消耗大 |
| 规划-执行分离 | 先出计划再逐步执行 | 长任务、步骤明确 | 计划僵化、难应对变化 |
| 多 Agent 协作 | 多个角色分工协作 | 复杂任务、需要多视角 | 通信开销大、调试困难 |
这张表不是让你照着选最复杂的,而是让你知道每往上走一层,你换来的是什么、付出的是什么。我见过有人上来就搭多 Agent 系统,结果任务简单到单轮调用就能解决,白白增加了三倍调试时间。架构选型的第一原则是:用能解决问题的最简架构。
2.3 选型时最该问自己的三个问题
在动手之前,我建议先回答三个问题,它们基本能帮你锁定架构:
- 任务需要几步?如果一步就能完成(比如"把这段话翻译成英文"),单轮调用足够。如果需要"先查再算再写",那就得上循环。
- 工具会不会互相依赖?如果工具之间没有依赖(比如查天气和查汇率互不相关),ReAct 循环就够。如果后一步依赖前一步的输出(比如先读文件再根据内容改文件),规划-执行分离更稳。
- 失败代价高不高?如果一次错误调用会写坏数据、发错消息,那必须加校验层,甚至上多 Agent 互相检查。如果只是查个信息,错了重试就行。
这三个问题我在每个项目启动前都会过一遍,实测下来能省掉大量返工。很多人架构选错,不是技术不行,是没想清楚任务本身长什么样。
3. 核心细节解析:Harness Runtime 到底要做什么
3.1 循环控制:什么时候继续、什么时候停
Harness Runtime 最核心的职责就是控制循环。一个典型的 Agent 循环长这样:
while not done: response = model.invoke(messages, tools) if response.has_tool_call: result = execute_tool(response.tool_call) messages.append(result) else: done = True final_answer = response.content看着简单,但魔鬼在细节里。第一个坑是终止条件。如果模型一直不返回最终答案,一直调工具,你的循环就会无限转下去。我踩过的坑是:模型在某个工具报错后,反复重试同一个工具,每次都失败,每次都重试,token 烧了一大把。后来我加了三道保险:
- 最大轮次限制:硬性限制循环次数,比如 15 轮,超过就强制终止并返回当前状态。
- 重复调用检测:如果连续三次调用同一个工具且参数相同,直接中断,提示模型换策略。
- 无进展检测:如果连续两轮的状态没有实质变化(比如工具返回内容一样),判定为卡死。
这三道保险不是可选项,是必选项。任何上生产的 Agent 都必须有,否则你迟早会遇到一个烧光预算的循环。
第二个坑是工具调用的错误处理。工具执行失败时,你不能直接把异常抛出去让整个 Agent 崩掉,也不能假装成功。正确做法是把错误信息作为工具返回值塞回消息历史,让模型看到"这个工具失败了,原因是 X",它才有机会换个方式。我一般会把错误分成三类处理:
- 可重试错误(网络超时):自动重试 2 次,仍失败则返回错误给模型。
- 参数错误(模型传了不存在的字段):直接把校验信息返回给模型,让它修正。
- 致命错误(权限不足、资源不存在):返回明确错误,并建议模型换工具或告知用户。
3.2 状态管理:消息历史怎么存、存多少
状态层最容易被忽视,但它决定了 Agent 能不能做长任务。核心问题是:消息历史越攒越长,token 迟早爆掉。我见过一个 Agent 跑到第 20 轮时,光历史消息就占了 8 万 token,模型开始胡言乱语。
解决思路有几种,我按推荐程度排:
- 滑动窗口 + 摘要:保留最近 N 轮完整消息,更早的用模型压缩成一段摘要。这是最实用的方案,我一般保留最近 6 到 8 轮完整对话,更早的压缩成 200 字以内的摘要。
- 关键信息提取:把工具返回的大块内容(比如整个文件)提取成关键字段存起来,原始内容丢弃。比如读文件后只保留"文件路径、行数、关键行内容",而不是整个文件文本。
- 外部存储:把完整历史存到数据库或文件,只在上下文里放引用 ID。适合超长任务,但增加了检索复杂度。
提示:状态压缩本身也要调模型,会额外消耗 token 和时间。我的经验是,只有当历史超过阈值(比如 10 轮或 2 万 token)时才触发压缩,不要每轮都压。
3.3 工具定义:模型能不能用好,全看这里
工具层是 Agent 能力的边界。模型再聪明,工具定义得烂,它也调不对。我总结了几条工具定义的硬规矩:
- 描述要写"什么时候用",不只是"是什么"。比如一个查订单的工具,描述里要写"当用户询问订单状态、物流进度、预计送达时间时使用",而不是只写"查询订单"。
- 参数要少而明确。一个工具超过 5 个参数,模型出错率会明显上升。参数名要自解释,别用
arg1、param2这种。 - 返回值要结构化。返回 JSON 比返回一段自然语言好,模型解析更稳。同时要控制返回长度,别把整个数据库 dump 回去。
- 工具数量要克制。我实测下来,单次给模型超过 15 个工具,选择准确率会下降。如果工具很多,考虑分组或分层暴露。
这里有个反直觉的点:工具不是越多越好。我早期恨不得把所有能力都做成工具,结果模型经常选错。后来我把工具砍到 8 个核心的,准确率反而上去了。工具多了,模型的选择空间变大,决策难度指数级上升。
3.4 提示词工程:系统提示词是 Agent 的"宪法"
系统提示词在 Agent 里的作用,比在普通对话里重要得多。它不只是设定人设,更是规定行为边界和决策规则。我写系统提示词一般包含这几块:
- 角色和职责:这个 Agent 是干什么的,不干什么。
- 工具使用规则:什么情况下用哪个工具,什么情况下不用。
- 输出格式要求:最终答案长什么样,中间步骤要不要暴露。
- 失败处理策略:工具失败时怎么办,不确定时怎么办。
- 禁止事项:明确列出不能做的事,比如不能编造工具返回结果。
我踩过的一个坑是:系统提示词写得太"温柔",模型遇到不确定的情况就自己编。后来我在提示词里加了一句"如果工具没有返回你需要的信息,必须明确说明信息不足,禁止推测",编造情况明显减少。提示词里的禁止项,比鼓励项更重要。
4. 实操过程:从零搭一个可用的 Harness Runtime
4.1 最小可用版本:50 行代码跑通循环
先给一个能跑的最小版本,用伪代码表示,你可以直接映射到自己的技术栈:
def run_agent(user_input, max_turns=15): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input} ] for turn in range(max_turns): response = call_model(messages, tools=TOOL_SCHEMAS) if not response.tool_calls: return response.content messages.append(response.as_message()) for call in response.tool_calls: if is_duplicate(call, messages): messages.append(tool_error(call, "重复调用,请换策略")) continue try: result = execute_tool(call.name, call.args) messages.append(tool_result(call.id, result)) except Exception as e: messages.append(tool_error(call.id, str(e))) return "达到最大轮次,任务未完成"这 50 行里已经包含了循环、工具执行、错误处理、重复检测、轮次限制。先把这个跑通,再往上加东西。我见过太多人一上来就设计复杂的多 Agent 架构,结果连最基本的循环都没跑稳。
4.2 加上状态压缩:让长任务不爆 token
在上面基础上加状态压缩,核心逻辑是:
def maybe_compress(messages, threshold=20000): if count_tokens(messages) < threshold: return messages recent = messages[-8:] older = messages[:-8] summary = call_model([ {"role": "system", "content": "把以下对话压缩成200字以内的摘要,保留关键事实和未完成的任务"}, {"role": "user", "content": serialize(older)} ]) return [{"role": "system", "content": f"历史摘要:{summary}"}] + recent这里有个细节:压缩时要把"未完成的任务"单独保留。我踩过的坑是,压缩后模型忘了用户最初的要求,开始答非所问。后来我在摘要 prompt 里明确要求保留"用户原始诉求"和"当前进度",问题就解决了。
4.3 工具执行的安全边界
工具执行是 Agent 最危险的部分,因为它真的会动外部资源。我一般设三道边界:
- 白名单:只有注册过的工具能执行,模型传未知工具名直接拒绝。
- 参数校验:每个工具执行前校验参数类型和范围,比如文件路径必须在允许目录内。
- 超时控制:每个工具执行设超时,比如 30 秒,超时直接返回错误,不让整个 Agent 卡死。
注意:涉及写操作的工具(写文件、发请求、改数据)一定要加确认机制。我的做法是,写操作先返回"待确认",让模型在下一轮明确请求执行,或者直接交给人工确认。宁可多一轮,不要写错数据。
4.4 日志与可观测性:出问题时你能查到什么
Agent 出问题是常态,关键是你能不能快速定位。我要求每个 Agent 必须记录这几类日志:
- 每轮输入输出:模型看到了什么、返回了什么。
- 工具调用记录:调了什么工具、参数是什么、返回什么、耗时多少。
- 状态变化:消息历史长度、token 数、压缩触发点。
- 异常堆栈:任何报错的完整信息。
这些日志我一般存成结构化 JSON,方便检索。有一次线上 Agent 突然开始乱答,我翻日志发现是某个工具返回了超长内容,把上下文挤爆了。没有日志的话,这种问题能查一整天。
5. 常见问题与排查技巧实录
5.1 模型不调工具,直接编答案
这是最高频的问题。模型明明有工具可用,却直接凭记忆回答。原因通常有三个:工具描述不够明确、系统提示词没强调必须用工具、或者模型本身能力不足。
排查顺序:先看工具描述是不是写清楚了"什么时候用",再看系统提示词有没有"必须基于工具返回回答"的硬性要求,最后考虑换模型。我实测下来,把工具描述从"查询天气"改成"当用户询问任何地点的天气情况时,必须调用此工具获取实时数据",不调工具的情况能减少八成。
5.2 工具调用参数总是错
参数错误通常是因为参数名不直观、类型没约束、或者缺少示例。解决办法是在工具 schema 里加description和example,把每个参数的含义和格式写清楚。比如日期参数,明确写"格式 YYYY-MM-DD,例如 2024-01-15"。另外,参数校验失败时,把具体的校验错误返回给模型,它下一轮通常能自己修正。
5.3 循环停不下来
前面提过,三道保险必须加:最大轮次、重复检测、无进展检测。除此之外,还有一个隐蔽原因:模型在等一个永远不会来的工具结果。比如它调了一个异步工具,但你的 Runtime 没等结果就进入下一轮。解决办法是确保工具执行是同步阻塞的,或者在异步场景下明确等待。
5.4 多轮之后质量下降
这是上下文污染导致的。早期轮次的错误信息、无关内容会干扰后续推理。解决办法就是状态压缩,把早期内容摘要化。另外,我习惯在每轮开始时,把"当前任务目标"重新强调一遍,放在消息历史的最前面,防止模型跑偏。
5.5 常见问题速查表
| 问题现象 | 最可能原因 | 快速排查 |
|---|---|---|
| 不调工具直接答 | 工具描述模糊/提示词没要求 | 检查工具描述和系统提示词 |
| 参数总出错 | 参数定义不清/缺示例 | 补 description 和 example |
| 循环停不下来 | 缺终止条件/重复调用 | 加轮次限制和重复检测 |
| 多轮后质量降 | 上下文污染 | 启用状态压缩 |
| 工具执行卡死 | 缺超时控制 | 每个工具加超时 |
| 结果不稳定 | 温度参数过高 | 降到 0 到 0.3 |
5.6 几个我踩过的坑
第一个坑是过早优化。我一开始就想着做多 Agent 协作,结果两个 Agent 互相等对方输出,死锁了。后来退回单 Agent 循环,问题迎刃而解。架构不是越复杂越好,是越匹配任务越好。
第二个坑是忽视 token 成本。一个 ReAct 循环跑 15 轮,每轮都带完整历史,token 消耗是单次调用的几十倍。上线前一定要算清楚成本,必要时用更小的模型做工具选择,大模型只做最终生成。
第三个坑是工具返回值太长。有个工具返回了整个表格,几千行,直接把上下文撑爆。后来我改成只返回前 20 行加一个"共 N 行"的提示,模型需要更多再单独查。
6. 架构演进路线:什么时候该往上加一层
6.1 从单轮到循环的触发信号
什么时候该从单轮调用升级到 ReAct 循环?我的判断标准是:当任务需要"根据中间结果决定下一步"时。如果用户的问题需要先查 A 再根据 A 的结果决定查 B,那单轮就不够了。反过来,如果所有信息都能一次性拿到,单轮更快更省。
6.2 从循环到规划-执行的触发信号
当任务步骤超过 5 步、且步骤之间有明确依赖时,ReAct 循环会变得低效,因为模型每步都要重新推理全局。这时候规划-执行分离更合适:先让模型出一个完整计划,再逐步执行,每步只关注当前子任务。代价是计划可能僵化,遇到意外需要重新规划。
6.3 从单 Agent 到多 Agent 的触发信号
多 Agent 只在两种情况下值得:任务需要不同专业视角(比如一个写代码一个审查),或者需要并行处理独立子任务。如果只是任务复杂,单 Agent 加更多工具通常就够了。多 Agent 的通信开销和调试难度是实打实的,别为了架构好看而上。
6.4 我的实际演进路径
我自己的项目是这样走的:第一版单轮调用,第二版加 ReAct 循环和 3 个工具,第三版加状态压缩和错误处理,第四版才引入规划-执行分离。每一步都是被实际问题逼出来的,不是提前设计的。架构应该跟着问题走,而不是跟着潮流走。你现在如果还在第一版,别急着跳到第四版,先把循环跑稳,把工具定义好,把日志打全,这些基础做扎实了,往上加层是水到渠成的事。
最后分享一个我一直在用的小技巧:每次给 Agent 加新能力之前,先手动模拟一遍完整流程,把每一步的输入输出写下来。如果手动流程里出现了"这里需要判断一下再决定",那这个地方就是需要循环或分支的信号。手动跑不通的流程,交给 Agent 也跑不通。这个习惯帮我省掉了大量无效开发。