1. 从一次裸调用说起:为什么单次 LLM 调用撑不起一个 Agent
很多人第一次接触 Agent,脑子里想的其实很简单:我调一次大模型接口,把用户的问题丢进去,模型返回一段文本,任务就完成了。这个认知在聊天机器人阶段没问题,但一旦你想让模型去查数据库、读文件、调外部接口、根据中间结果决定下一步动作,单次调用立刻就不够用了。
原因很直接:单次 LLM 调用是一个无状态、单轮、无工具、无记忆的函数。它的输入是一段 prompt,输出是一段文本,中间没有任何"决策—执行—观察—再决策"的循环。而 Agent 的本质,恰恰是这个循环。你可以把单次调用理解成"问路人一句话",而 Agent 是"派一个实习生去把这件事办完"——实习生要自己看情况、找工具、试错、汇报。
所以当我们讨论"AI Agent 架构怎么选"时,真正在讨论的是:从一次裸调用,到一套能稳定跑起来的 Harness Runtime,中间要补哪些层、每层解决什么问题、哪些能省哪些不能省。这里的 Harness Runtime 我指的是包裹在模型外面的那套"驾驭层"——它负责编排循环、管理工具、维护上下文、处理错误、控制预算、记录轨迹。模型是发动机,Harness 是底盘、变速箱和方向盘。
这篇文章面向的是已经能跑通一次 LLM 调用、准备把它做成真正能干活的 Agent 的开发者。不管你是用 Python 手搓,还是基于现成框架二次开发,下面这套分层思路都能直接对照落地。我会从最朴素的循环讲起,一层层往上加,每加一层都告诉你为什么加、不加会出什么问题、加了之后代价是什么。
先说结论性的判断:Agent 架构的选型,本质是在"可控性"和"自主性"之间找平衡点。越往自主方向走,Harness 要承担的兜底责任越重。很多人失败不是因为模型不行,而是因为 Harness 太薄,模型一放飞就收不回来。
2. 四层架构拆解:从裸调用到 Harness Runtime 的完整分层
2.1 第一层:单次调用层,先把它跑稳
单次调用层看起来最简单,但它是所有上层建筑的地基,这里埋的坑会在后面被放大十倍。这一层要解决的核心问题有三个:请求怎么发、返回怎么解析、异常怎么兜。
请求发送这块,最容易被忽视的是超时和重试策略。我见过太多项目直接用默认超时,结果模型偶尔卡住三十秒,整个 Agent 循环就僵在那里。合理的做法是给单次调用设置一个明确的超时上限,比如 60 秒,超过就中断并进入重试逻辑。重试不能无脑重试,要区分错误类型:网络类错误可以退避重试,参数类错误重试多少次都没用,直接抛出。
返回解析这块,关键是要把模型的自由文本转成结构化数据。裸调用返回的是一段字符串,但 Agent 需要的是"我要调用哪个工具、参数是什么"这种结构化意图。常见做法是要求模型输出 JSON,然后用解析器提取。这里有个实操心得:不要指望模型每次都输出完美 JSON,一定要写容错解析——比如先尝试标准 JSON 解析,失败后用正则提取代码块内容,再失败就触发一次"修复重试",把错误信息回传给模型让它重新输出。
import json import re def parse_tool_call(text): # 第一优先:直接解析 try: return json.loads(text) except json.JSONDecodeError: pass # 第二优先:提取代码块 match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', text, re.DOTALL) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: pass # 第三优先:提取第一个完整花括号块 match = re.search(r'\{.*\}', text, re.DOTALL) if match: try: return json.loads(match.group(0)) except json.JSONDecodeError: pass return None异常兜底这块,核心原则是任何一次调用失败都不能让整个 Agent 崩溃。要把每次调用包在一个 try 里,失败时返回一个结构化的错误对象,让上层决定是重试、降级还是终止。这一层做扎实了,上面的循环层才有稳定的基础。
2.2 第二层:循环控制层,Agent 的心跳
循环控制层是 Agent 区别于普通调用的分水岭。它的职责是:反复调用模型,直到任务完成或触发终止条件。听起来简单,但这里藏着 Agent 架构里最多的坑。
最朴素的循环长这样:把用户输入发给模型,模型返回要么是最终答案,要么是工具调用请求;如果是工具调用,执行工具,把结果塞回上下文,再调模型;如此往复。这个循环必须有两个硬性终止条件:最大轮数和最大 token 预算。没有这两个,一个逻辑打转的 Agent 能把你 API 账单烧穿。
最大轮数怎么定?我的经验是,简单任务 5 到 8 轮,中等复杂度 15 到 20 轮,复杂多步任务 30 轮封顶。超过这个数还没收敛,基本说明任务描述有问题或者模型陷入了死循环,继续跑下去也是浪费。token 预算同理,要按任务复杂度设一个上限,比如单次任务总消耗不超过 10 万 token。
循环里还有一个关键设计:状态如何传递。每一轮的工具执行结果都要追加到消息历史里,但历史不能无限增长。这里就引出上下文管理的问题,我放到下一层细讲。循环层本身要保证的是:每一轮都能拿到完整的、经过裁剪的上下文,执行完工具后能正确地把结果合并回去。
注意:循环层一定要记录每一轮的完整轨迹——输入、模型输出、工具调用、工具结果、耗时。这份轨迹在调试时价值极高,出问题时你能一眼看出是哪一轮开始跑偏的。
2.3 第三层:工具与上下文层,Agent 的手和记忆
工具层解决"Agent 能做什么",上下文层解决"Agent 记得什么"。这两块是 Agent 能力的天花板。
工具层的设计要点,第一是工具描述的质量。模型选不选对工具,八成取决于工具描述写得好不好。一个好的工具描述要包含:这个工具干什么、什么情况下用、参数含义、返回什么。我见过太多项目工具描述就一行字,然后抱怨模型乱调工具。第二是参数校验。模型生成的参数经常有类型错误、缺字段、格式不对,工具执行前必须校验,校验失败要把明确的错误信息返回给模型,让它自己修正。第三是工具数量控制。工具不是越多越好,超过 20 个工具后模型的选择准确率会明显下降。如果确实需要很多工具,要做分组或分层路由。
上下文层是 Agent 架构里最考验工程能力的地方。核心矛盾是:模型需要足够的历史来理解当前状态,但上下文窗口有限,塞太多会稀释注意力还费钱。常见的处理策略有这么几种,我列个表对比一下。
| 策略 | 做法 | 适用场景 | 代价 |
|---|---|---|---|
| 全量保留 | 所有历史都塞进去 | 短任务、轮数少 | 很快超窗口 |
| 滑动窗口 | 只保留最近 N 轮 | 长对话、状态简单 | 丢失早期关键信息 |
| 摘要压缩 | 把早期历史总结成一段 | 长任务、需保留脉络 | 摘要本身有信息损失 |
| 关键信息抽取 | 只保留工具结果和决策点 | 结构化任务 | 实现复杂 |
实操中我一般用滑动窗口加摘要的组合:最近 5 轮完整保留,更早的压缩成一段摘要。摘要不是简单截断,而是让模型自己总结"到目前为止完成了什么、当前状态是什么、还剩什么没做"。这样既控制了长度,又保住了任务脉络。
2.4 第四层:Harness Runtime,把一切串起来的驾驭层
到了这一层,前面三层都是它的组件。Harness Runtime 的职责是统一调度、统一治理、统一观测。它要管的事情包括:任务的生命周期管理、多 Agent 之间的协调、预算和权限控制、错误恢复、轨迹记录、以及对外暴露统一的接口。
为什么需要单独一个 Runtime 层?因为当你的 Agent 从"一个循环"变成"多个循环协作"时,如果没有统一的驾驭层,各个 Agent 各自为政,状态会乱成一锅粥。Runtime 就像操作系统,Agent 就像进程,它负责分配资源、调度执行、处理异常。
Runtime 里最值得投入的是可观测性。一个跑起来的 Agent 系统,如果没有完整的轨迹记录和指标采集,出问题你根本无从下手。要记录的核心指标包括:每轮耗时、token 消耗、工具调用成功率、循环轮数分布、任务完成率。这些数据积累起来,你才能知道系统到底健不健康。
3. 关键选型决策:每个岔路口该怎么走
3.1 单 Agent 还是多 Agent,别一上来就上多 Agent
这是被问得最多的问题。我的建议很明确:能用单 Agent 解决就别上多 Agent。多 Agent 的复杂度不是线性增长,是指数增长——通信开销、状态同步、冲突解决、调试难度,每一样都能让你怀疑人生。
单 Agent 适合的场景:任务边界清晰、工具集不大、不需要并行。多 Agent 真正有价值的场景是:任务可以明确拆分成独立子任务、子任务之间需要不同专长、或者需要并行加速。比如一个"调研加写作"的任务,调研 Agent 和写作 Agent 分工明确,这种拆分是合理的。
判断标准可以简化成一句话:如果两个子任务之间需要频繁来回沟通,那它们就不该拆成两个 Agent。频繁通信的多 Agent 系统,性能往往还不如一个单 Agent 加更多工具。
3.2 ReAct 还是 Plan-and-Execute,看任务确定性
ReAct 是"边想边做",每一步根据当前观察决定下一步。Plan-and-Execute 是"先规划再执行",先出一份完整计划,再逐步执行。
ReAct 的优势是灵活,适合探索性任务,比如"帮我找出这个 bug 的原因",你事先不知道要查哪些地方。劣势是容易短视,可能走弯路。Plan-and-Execute 的优势是有全局观,适合步骤明确的任务,比如"把这份数据按这个规则处理并生成报告"。劣势是计划一旦出错,后面全错,而且任务中途变化时不好调整。
实操中我常用混合模式:先让模型出一个粗粒度计划,然后按 ReAct 方式执行每一步,执行过程中允许修改后续计划。这样既有全局方向,又保留了灵活性。
3.3 工具调用用原生还是文本解析,看模型支持
现在很多模型提供原生的 function calling 或 tool use 接口,返回结构化的工具调用请求。这比让模型输出文本再解析要可靠得多。如果模型支持原生工具调用,优先用原生。如果不支持,才退回到文本解析方案。
原生方案的好处是解析稳定、参数类型明确。代价是灵活性稍差,有些复杂的嵌套调用表达起来不如自由文本。文本解析方案的好处是灵活,坏处是解析容错要做得很扎实。我的建议是:能用原生就用原生,把文本解析作为兜底。
4. 实操落地:从零搭一个最小可用 Harness
4.1 核心循环的实现骨架
下面这段代码是一个最小可用的 Agent 循环骨架,去掉了具体业务逻辑,保留了核心结构。你可以直接对照改成自己的实现。
class AgentRuntime: def __init__(self, llm_client, tools, max_turns=15, max_tokens=100000): self.llm = llm_client self.tools = {t.name: t for t in tools} self.max_turns = max_turns self.max_tokens = max_tokens self.trajectory = [] def run(self, task): messages = [{"role": "system", "content": self.system_prompt()}, {"role": "user", "content": task}] total_tokens = 0 for turn in range(self.max_turns): # 上下文裁剪 messages = self.trim_context(messages) # 调用模型 response = self.llm.chat(messages, tools=self.tool_schemas()) total_tokens += response.usage.total_tokens self.trajectory.append({"turn": turn, "response": response}) # 预算检查 if total_tokens > self.max_tokens: return self.budget_exceeded_result() # 判断是否结束 if not response.tool_calls: return response.content # 执行工具 messages.append(response.as_message()) for call in response.tool_calls: result = self.execute_tool(call) messages.append({"role": "tool", "content": result, "tool_call_id": call.id}) return self.max_turns_result() def execute_tool(self, call): tool = self.tools.get(call.name) if not tool: return f"错误:工具 {call.name} 不存在" try: params = self.validate_params(tool, call.arguments) return tool.run(**params) except Exception as e: return f"工具执行失败:{str(e)},请检查参数后重试"这段骨架里有几个设计点值得说明。trim_context是上下文裁剪的入口,具体策略可以替换。execute_tool里的错误处理很关键——工具失败不能直接抛异常中断循环,而是要把错误信息作为工具结果返回给模型,让模型有机会自我修正。这是 Agent 鲁棒性的重要来源。
4.2 上下文裁剪的具体实现
上下文裁剪是 Harness 里最需要调优的部分。下面是一个滑动窗口加摘要的实现思路。
def trim_context(self, messages, keep_recent=5, max_chars=20000): # 系统消息永远保留 system_msgs = [m for m in messages if m["role"] == "system"] dialog = [m for m in messages if m["role"] != "system"] # 长度没超,直接返回 total = sum(len(str(m.get("content", ""))) for m in messages) if total <= max_chars: return messages # 保留最近 N 轮 recent = dialog[-keep_recent * 2:] older = dialog[:-keep_recent * 2] # 早期历史压缩成摘要 if older: summary = self.summarize(older) summary_msg = {"role": "system", "content": f"【历史摘要】{summary}"} return system_msgs + [summary_msg] + recent return system_msgs + recentsummarize方法让模型把早期对话压缩成一段话,重点保留"已完成什么、当前状态、待办事项"。这里有个经验:摘要的 prompt 要明确要求保留任务状态和关键决策,否则模型容易总结成流水账,丢掉真正有用的信息。
4.3 工具定义与校验的规范
工具定义我建议用统一的 schema,包含名称、描述、参数定义、执行函数。参数校验用 JSON Schema 做,校验失败返回明确错误。
TOOL_SCHEMA = { "name": "query_database", "description": "根据条件查询数据库。当用户需要获取结构化数据时使用。", "parameters": { "type": "object", "properties": { "table": {"type": "string", "description": "表名"}, "conditions": {"type": "object", "description": "查询条件键值对"}, "limit": {"type": "integer", "description": "返回条数上限,默认10"} }, "required": ["table"] } }工具描述里那句"当用户需要获取结构化数据时使用"很重要,它告诉模型什么时候该用这个工具,而不只是这个工具是什么。很多工具选择错误就是因为描述里缺了使用场景。
5. 常见问题与排查技巧实录
5.1 模型陷入死循环怎么办
这是最常见的故障。表现是 Agent 反复调用同一个工具、反复输出相似内容、轮数用尽还没结果。排查思路:先看轨迹,确认是从哪一轮开始打转的。常见原因有三个:工具返回的结果让模型误以为没成功、任务描述本身有歧义、上下文里缺少"已经做过什么"的记录。
解决办法:在系统提示里明确要求"不要重复执行已经成功过的操作",并在工具结果里带上明确的状态标记。如果某个工具连续失败三次,强制中断并返回错误,而不是让它无限重试。
5.2 工具调用参数总是出错
参数错误通常有两个来源:工具描述不清楚,或者模型对参数格式理解有偏差。排查时先把出错的具体参数和模型输出对照看。如果是描述问题,补充参数示例;如果是格式问题,在描述里明确给出格式模板。还有一个技巧:在参数校验失败时,把 JSON Schema 的校验错误信息原样返回给模型,模型看到具体哪里不对,修正成功率会高很多。
5.3 上下文超长导致性能下降
上下文越长,模型响应越慢、越贵,而且注意力会被稀释,关键信息反而被淹没。排查时统计每轮的实际 token 数,看是不是在无节制增长。解决办法就是前面讲的裁剪策略。这里补充一个技巧:工具返回结果要做截断。有些工具返回一大段文本,直接塞进上下文会瞬间撑爆。要在工具层就做截断,只返回关键部分,或者返回摘要加"完整结果已保存"的提示。
5.4 任务完成率低的排查清单
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 轮数用尽未完成 | 任务太复杂或描述不清 | 拆分任务、补充说明 |
| 工具选错 | 工具描述质量差 | 优化描述、加使用场景 |
| 参数错误多 | schema 不清晰 | 补充示例和格式说明 |
| 中途跑偏 | 上下文丢失关键信息 | 检查裁剪策略 |
| 响应慢 | 上下文过长 | 优化裁剪、截断工具结果 |
5.5 几个踩过坑才明白的经验
第一个经验:日志要记全,但别记太细。每轮的输入输出、工具调用、耗时都要记,但不要把完整的上下文每次都 dump 一遍,那样日志文件会爆炸。记关键字段就够了。
第二个经验:给 Agent 设一个"求助"出口。当它连续失败或者不确定时,应该能主动停下来问用户,而不是硬着头皮瞎试。这个出口在系统提示里加一句"如果连续两次尝试都失败,请向用户说明情况并请求澄清"就能实现。
第三个经验:预算控制要前置。不要等 token 用完了才发现,要在每轮开始前预估这一轮大概要花多少,超了就提前终止。事后统计只能让你知道花了多少,事前控制才能帮你省钱。
第四个经验:测试用例要覆盖失败路径。大部分人测试 Agent 只测正常流程,结果上线后一遇到工具报错就崩。要专门构造工具失败、参数错误、超时这些场景来测,确保 Harness 的兜底逻辑真的管用。
6. 性能与成本优化的几个实操手段
Agent 跑起来之后,成本和延迟就成了绕不开的问题。这里分享几个我实际用下来有效的优化手段。
缓存重复调用。很多 Agent 任务里,相同的工具调用会重复出现,比如反复查同一张表。在工具层加一层结果缓存,命中就直接返回,能省下不少调用。缓存 key 用工具名加参数的哈希,注意设置合理的过期时间。
并行执行独立工具。如果一轮里模型请求了多个互不依赖的工具调用,可以并行执行,而不是串行等待。这能显著降低多工具场景的延迟。实现上用线程池或异步任务都行,关键是判断工具之间有没有依赖。
分级模型策略。不是所有步骤都需要最强的模型。简单的意图判断、参数提取可以用小模型,复杂的推理和规划用大模型。在 Harness 里做一层路由,根据任务类型选择模型,成本能降一大截。
精简系统提示。系统提示每轮都要传,如果写得太长,累积起来的 token 消耗很可观。定期审视系统提示,删掉冗余的说明,把能合并的规则合并。我见过一个项目系统提示写了三千字,精简到八百字后效果没变,成本降了不少。
工具结果压缩。工具返回的大段文本,在塞进上下文前先做压缩。比如返回一个列表,只保留前几条加总数;返回一段长文本,提取关键字段。这个压缩逻辑要针对具体工具定制,通用压缩容易丢信息。
7. 从能跑到好用:Harness 成熟度的几个标志
一个 Harness 从"能跑通"到"能放心用",中间有几个明显的台阶。第一个台阶是错误可恢复:任何单点失败都不会导致整个任务崩溃,要么自动重试,要么优雅降级。第二个台阶是行为可预测:相同输入下,Agent 的行为基本一致,不会这次这样下次那样。第三个台阶是成本可控:每个任务的消耗有明确上限,不会出现意外的高额账单。第四个台阶是问题可定位:出问题时能通过轨迹快速找到根因,而不是靠猜。
达到这四个台阶,你的 Harness 才算真正成熟。而这一切的基础,就是前面讲的分层设计——每一层各司其职,层与层之间接口清晰。单次调用层保证基础稳定,循环层保证流程可控,工具与上下文层保证能力边界,Runtime 层保证全局治理。
我个人在实际操作中的体会是,Agent 架构选型最忌讳的就是"一步到位"。不要一上来就追求多 Agent、复杂编排、花哨的工具生态。先把单 Agent 加少量工具跑稳,把循环、上下文、错误处理这些基础打扎实,再根据实际需求往上加。大部分场景下,一个设计良好的单 Agent Harness,能力已经足够覆盖需求了。真正需要多 Agent 的复杂场景,其实比想象中少得多。