1. 从一次裸调用说起:为什么单次 LLM 调用撑不起一个 Agent
很多人第一次接触 Agent,脑子里想的其实还是“给大模型发一段话,它回一段话”。这个认知在写 Demo 的时候没问题,但一旦你想让它去查资料、改文件、跑命令、连续完成一个多步骤任务,就会立刻撞墙。原因不复杂:单次 LLM 调用本质上是一个无状态、无记忆、无行动能力的文本变换函数。你给它输入,它给你输出,中间没有任何“循环”和“反馈”。
我早期做过一个很典型的实验:让模型“帮我统计某个目录下所有日志文件里出现次数最多的错误码”。如果只做一次调用,模型只能凭训练时的印象瞎猜,因为它根本看不到你的文件系统。你可能会说,那我把文件内容塞进 prompt 不就行了?问题是文件可能几十兆,上下文塞不下;就算塞得下,模型也没法“执行”统计这个动作,它只能“描述”统计该怎么做。
所以 Agent 的第一个本质,是把“一次调用”升级成“一个循环”。这个循环里,模型不再只负责生成文本,而是负责生成“下一步该做什么”的决策,然后由外部运行时去执行这个决策,再把执行结果喂回给模型,让它继续决策。这个“决策—执行—反馈”的闭环,才是 Agent 和普通 Chatbot 的分水岭。
那这个循环具体长什么样?最朴素的版本可以拆成四步:
- 把用户目标 + 历史上下文 + 可用工具列表,组装成一个 prompt 发给 LLM。
- LLM 返回一个结构化的“动作”,比如
{"tool": "read_file", "args": {"path": "a.log"}}。 - 运行时解析这个动作,真正去调用对应的函数,拿到结果。
- 把结果作为新的观察(observation)追加到上下文,回到第 1 步,直到 LLM 返回“任务完成”。
这四步看起来简单,但每一步都藏着工程决策。比如第 1 步里,历史上下文怎么裁剪?工具列表怎么描述模型才不容易选错?第 2 步里,怎么保证模型输出的 JSON 一定能被解析?第 3 步里,工具执行超时了怎么办?第 4 步里,上下文越来越长,什么时候该压缩?这些问题,就是“从一次调用到完整 Harness Runtime”这条路上必须回答的。
我个人的判断是:Agent 的复杂度不在模型本身,而在模型外面那层“运行时”。模型是大脑,但大脑再聪明,没有手脚、没有记忆、没有安全边界,也干不成事。这层运行时,业内常叫 Harness,也有人叫 Agent Runtime、Agent Framework。名字不重要,重要的是它要解决四类问题:循环控制、工具调度、上下文管理、安全与可观测。下面我就按这四块,把架构选型和实现方案掰开讲。
2. 架构选型:四种主流形态与它们的适用边界
在动手写代码之前,先想清楚你要的是哪一档架构。我见过太多人一上来就堆框架,结果项目里 80% 的功能用不上,调试还特别痛苦。Agent 架构大致可以分成四档,从轻到重,各有各的甜区。
2.1 单轮工具调用:最轻的“伪 Agent”
这一档严格来说不算 Agent,它只是让模型在一次回复里决定调用哪个工具,然后你把结果拼回去再问一次。典型场景是“查天气”“算个数”“搜一下最新文档”。它的实现成本极低,一次请求里带上 tools 定义,模型返回 tool_calls,你执行完把结果作为 tool message 追加,再发一次请求拿最终答案。
它的优点是可控、便宜、延迟低。缺点是没有真正的多步规划,模型看不到“执行结果之后再决定下一步”的完整循环,遇到需要三五步才能完成的任务就会露怯。我的经验是:如果你的任务 90% 的情况下一两步就能搞定,别上重架构,单轮工具调用足够了。强行上循环,只会让延迟翻倍、成本翻倍,稳定性还下降。
2.2 ReAct 循环:最经典的 Agent 骨架
ReAct(Reason + Act)是很多人入门 Agent 的第一站。它的核心就是前面说的那个四步循环,模型在每一步先“想一下”(Thought),再决定“做什么”(Action),然后拿到“观察”(Observation),继续下一轮。这个模式的好处是通用性强、逻辑直观,几乎任何多步任务都能套进去。
但 ReAct 有个很现实的坑:它非常依赖模型输出格式的稳定性。早期我用纯文本 prompt 让模型输出Thought: ... Action: ...,结果模型时不时给你加个 markdown 代码块、加个解释性前缀,解析器就崩了。后来改用结构化输出(JSON schema 或 function calling),稳定性才上来。所以如果你要自己实现 ReAct,第一件事不是写循环,而是把输出格式约束死。
另一个坑是循环终止条件。模型有时候会陷入“我再确认一下”的死循环,反复调用同一个工具。我的做法是设一个硬上限,比如最多 15 轮,超过就强制让它基于现有信息给结论。同时加一个“重复动作检测”,如果连续两轮调用的工具和参数完全一样,直接打断,把结果返回给用户并说明情况。
2.3 Plan-and-Execute:先规划再执行的两段式
ReAct 是“走一步看一步”,Plan-and-Execute 则是“先画地图再走路”。它先用一次 LLM 调用把任务拆成一个步骤列表,然后逐步执行,每执行完一步可以选择性地重新规划。这种架构适合步骤多、依赖关系明确的任务,比如“调研某个主题并写一份报告”,你可以先规划出“搜集资料—整理要点—撰写初稿—润色”这样的大纲。
它的优势是全局视野好、token 消耗更可控,因为规划阶段一次性想清楚,执行阶段就不用每步都带着完整历史去问模型。缺点是规划可能脱离实际,模型在没看到真实数据前拍脑袋定的步骤,执行到一半发现走不通,就得回炉重规划。我的经验是:规划粒度别太细,粗到“阶段”级别就行,细活留给执行阶段用 ReAct 处理。
2.4 多 Agent 协作:最重也最容易翻车的形态
多 Agent 就是让多个角色(比如“研究员”“程序员”“审查员”)各自跑自己的循环,通过消息传递协作。听起来很美好,实际做起来调试成本极高。我踩过的最大的坑是:两个 Agent 互相等对方输出,结果死锁;或者一个 Agent 把另一个 Agent 的中间结果当成最终结论,错误层层放大。
我的建议很直接:除非你的任务天然可以并行拆分,且单 Agent 确实扛不住,否则别上多 Agent。大部分所谓“需要多 Agent”的场景,其实一个 Agent 加几个工具就能解决。真要用,也要先定义清楚通信协议、超时机制和失败回滚,否则就是给自己挖坑。
下面这张表是我对四档架构的选型总结,你可以直接对照自己的场景:
| 架构形态 | 实现成本 | 适用任务复杂度 | 主要风险 | 我的推荐场景 |
|---|---|---|---|---|
| 单轮工具调用 | 极低 | 1-2 步 | 无法多步规划 | 查询、计算、单次检索 |
| ReAct 循环 | 中 | 3-15 步 | 格式不稳、死循环 | 通用多步任务 |
| Plan-and-Execute | 中高 | 步骤明确的复杂任务 | 规划脱离实际 | 报告生成、流程编排 |
| 多 Agent 协作 | 高 | 可并行拆分的任务 | 死锁、错误放大 | 大规模并行调研 |
选型的原则就一句话:用能解决问题的最轻架构。架构不是越重越显得专业,越重意味着越多的失败点和越高的维护成本。
3. Harness Runtime 的四大核心模块拆解
确定了架构形态,接下来就是把这层运行时真正搭起来。我把它拆成四个模块:循环控制器、工具调度器、上下文管理器、安全与可观测层。这四个模块各司其职,任何一个缺失都会让 Agent 变成“看起来能跑,一用就崩”的玩具。
3.1 循环控制器:Agent 的心跳
循环控制器是整个 Runtime 的主干,它负责“什么时候问模型、什么时候执行工具、什么时候停”。一个健壮的循环控制器,至少要处理这几件事:
状态机设计。我习惯把 Agent 的状态定义成几个明确的枚举:IDLE、THINKING、ACTING、OBSERVING、DONE、ERROR。每次状态转移都有明确的触发条件,这样调试的时候一眼就能看出卡在哪。别用一堆布尔标志位去拼状态,那是维护噩梦。
轮次上限与预算控制。除了最大轮数,我还会加一个 token 预算。因为有些任务模型会疯狂调用工具,每轮都塞一大堆上下文,token 消耗像滚雪球。我的做法是给整个任务设一个总 token 上限,接近上限时强制进入“总结并结束”模式。
中断与恢复。真实场景里,用户可能中途想插话,或者任务跑太久需要暂停。循环控制器要支持在安全点(比如一轮结束后)检查中断信号,把当前状态序列化存下来,下次能接着跑。这个能力在长任务里特别重要。
3.2 工具调度器:让模型的手真正动起来
工具调度器负责把模型输出的“动作意图”翻译成真实的函数调用。这里有几个关键设计点:
工具描述的质量决定调用准确率。我见过太多人工具描述写得含糊,比如search(query)就写“搜索”,结果模型根本不知道该传什么。好的描述要包含:这个工具做什么、什么时候用、参数含义、返回什么、有什么限制。举个例子,search_docs的描述我会写成“在内部文档库中按关键词检索,返回最相关的 5 个片段,适用于查找已有资料,不适用于实时数据”。这样模型选错的概率会明显下降。
参数校验与容错。模型输出的参数经常有惊喜:该传数字传了字符串、该传数组传了单个值、路径里带了个不存在的目录。调度器必须在执行前做 schema 校验,校验失败时不要直接抛错,而是把错误信息作为 observation 返回给模型,让它自己修正。这个“自我修复”能力能大幅提升成功率。
超时与并发。工具执行一定要设超时,尤其是网络请求类工具。我一般设 30 秒,超时就返回“执行超时”让模型决定下一步。如果任务里有多个独立工具调用,可以考虑并发执行,但要注意并发写同一个资源时的冲突问题。
3.3 上下文管理器:Agent 的记忆与遗忘
上下文管理是很多人最容易忽视、但实际最影响效果的部分。Agent 跑十几轮之后,上下文可能已经几万 token,既贵又慢,还容易让模型“迷失在中间”。我的处理策略分三层:
短期记忆:保留最近 N 轮的完整对话,N 一般取 5 到 8。这部分保证模型对当前进展有清晰认知。
长期摘要:把更早的历史压缩成一段摘要,比如“前 10 轮完成了资料搜集,找到了 A、B、C 三个关键点”。摘要由模型自己生成,或者用一个便宜的小模型来生成,成本可控。
关键信息锚定:把任务目标、已确认的事实、当前待办,单独放在一个固定位置,每轮都带上。这样即使历史被压缩,核心信息也不会丢。
提示:上下文压缩不是越狠越好。我试过把历史压得太狠,结果模型忘了自己已经调用过某个工具,又调了一遍。压缩后一定要保留“已执行动作”的清单。
3.4 安全与可观测:别等出事才想起来
安全这块,核心是权限最小化和操作可审计。工具能访问什么资源,要提前划定边界。比如文件操作工具,只允许访问指定目录;命令执行工具,要过滤危险命令。别指望模型自己“懂事”,它不懂。
可观测性则是把每一步都记下来:模型输入输出、工具调用参数和结果、耗时、token 消耗、错误堆栈。这些日志在排查问题时是救命稻草。我一般会把一次任务的完整轨迹存成一个 JSON,出问题时直接回放,比看零散的日志高效得多。
4. 从零实现一个最小可用 Harness:完整实操
理论讲完,我们动手写一个能跑的最小 Harness。我用 Python 举例,核心逻辑不依赖任何特定框架,你可以直接抄去改。为了聚焦主干,我假设你已经有一个能调用的 LLM 接口,这里用伪代码表示。
4.1 定义工具与注册表
先定义工具的数据结构和注册表。每个工具包含名称、描述、参数 schema 和实际执行函数。
import json import time TOOL_REGISTRY = {} def register_tool(name, description, parameters, func): TOOL_REGISTRY[name] = { "name": name, "description": description, "parameters": parameters, "func": func, } def get_tool_schemas(): return [ { "name": t["name"], "description": t["description"], "parameters": t["parameters"], } for t in TOOL_REGISTRY.values() ]注册一个读文件的工具作为示例:
def read_file(path, max_chars=2000): try: with open(path, "r", encoding="utf-8") as f: content = f.read(max_chars) return {"ok": True, "content": content} except Exception as e: return {"ok": False, "error": str(e)} register_tool( name="read_file", description="读取指定路径的文本文件,返回前若干字符。适用于查看日志、配置等文本内容。", parameters={ "type": "object", "properties": { "path": {"type": "string", "description": "文件的绝对或相对路径"}, "max_chars": {"type": "integer", "description": "最多读取的字符数,默认 2000"}, }, "required": ["path"], }, func=read_file, )工具描述我特意写得具体,包括“适用于什么、不适用于什么”,这是提升调用准确率的关键。
4.2 循环控制器实现
下面是核心循环。注意我加了轮次上限、重复动作检测和错误兜底。
MAX_TURNS = 15 def run_agent(user_goal, llm_client, max_turns=MAX_TURNS): messages = [ {"role": "system", "content": "你是一个可以调用工具的助手。每一步先思考,再决定调用工具或给出最终答案。"}, {"role": "user", "content": user_goal}, ] last_action = None repeat_count = 0 for turn in range(max_turns): response = llm_client.chat(messages, tools=get_tool_schemas()) msg = response["message"] messages.append(msg) tool_calls = msg.get("tool_calls") if not tool_calls: return msg.get("content", "") for call in tool_calls: name = call["function"]["name"] args = json.loads(call["function"]["arguments"]) action_key = (name, json.dumps(args, sort_keys=True)) if action_key == last_action: repeat_count += 1 if repeat_count >= 2: messages.append({ "role": "tool", "tool_call_id": call["id"], "content": "检测到重复动作,请基于现有信息给出结论。", }) continue else: repeat_count = 0 last_action = action_key result = execute_tool(name, args) messages.append({ "role": "tool", "tool_call_id": call["id"], "content": json.dumps(result, ensure_ascii=False), }) return "已达到最大轮次,任务未完成。"execute_tool负责查表、校验参数、执行并捕获异常:
def execute_tool(name, args): tool = TOOL_REGISTRY.get(name) if not tool: return {"ok": False, "error": f"未知工具: {name}"} try: start = time.time() result = tool["func"](**args) result["_elapsed"] = round(time.time() - start, 3) return result except TypeError as e: return {"ok": False, "error": f"参数错误: {e}"} except Exception as e: return {"ok": False, "error": f"执行异常: {e}"}这段代码里,参数错误和执行异常都被转成结构化结果返回给模型,而不是直接崩掉。模型看到错误信息后,往往能自己调整参数重试,这就是前面说的“自我修复”。
4.3 上下文压缩的落地写法
当消息列表超过一定长度,就触发压缩。我的做法是保留 system、第一条 user 目标和最近若干轮,把中间部分交给模型总结。
def compress_messages(messages, llm_client, keep_recent=6): if len(messages) <= keep_recent + 2: return messages head = messages[:2] middle = messages[2:-keep_recent] tail = messages[-keep_recent:] summary_prompt = "请用简洁的中文总结以下对话中已完成的关键动作和结论:\n" summary_prompt += json.dumps(middle, ensure_ascii=False)[:6000] summary = llm_client.chat([ {"role": "user", "content": summary_prompt} ])["message"]["content"] return head + [{"role": "system", "content": f"历史摘要:{summary}"}] + tail这里有个细节:压缩时我把中间部分截断到 6000 字符再送去总结,避免总结本身又消耗大量 token。压缩的触发时机我一般设在消息数超过 20 条,或者估算 token 超过模型窗口的 60%。
4.4 一次完整运行的轨迹记录
把上面的模块串起来,跑一个“统计日志错误码”的任务,轨迹大概是这样:
- 用户目标进入,system prompt 带上工具列表。
- 模型第一轮返回
read_file(path="app.log")。 - 调度器执行,返回文件内容。
- 模型第二轮看到内容,返回
read_file(path="app.log", max_chars=5000)想读更多。 - 重复动作检测发现参数不同,放行。
- 模型第三轮基于内容给出统计结论。
整个过程我建议把每轮的turn、action、observation、elapsed都写进一个列表,任务结束后落盘。这个轨迹文件在复盘时价值极高,尤其是当模型给出错误结论时,你能清楚看到它是哪一步开始跑偏的。
5. 常见问题与排查技巧实录
Agent 跑不起来,十有八九是下面这几类问题。我把它们整理成速查表,附上我的排查思路。
| 现象 | 可能原因 | 排查方法 | 解决手段 |
|---|---|---|---|
| 模型不调用工具,直接回答 | 工具描述不清、system prompt 没强调 | 看模型输出是否提到工具 | 强化描述,明确“必须用工具获取事实” |
| 工具调用参数解析失败 | 模型输出非法 JSON | 打印原始 arguments 字符串 | 用结构化输出约束,或加 JSON 修复重试 |
| 循环不终止 | 终止条件缺失、模型反复确认 | 看轮次和重复动作 | 设最大轮次 + 重复动作检测 |
| 上下文超长报错 | 历史未压缩 | 统计每轮 token 数 | 触发压缩,保留摘要和最近轮次 |
| 工具执行超时卡死 | 无超时机制 | 看耗时日志 | 加超时,超时返回错误让模型决策 |
| 结论与事实不符 | 模型忽略 observation | 对比轨迹中工具结果与最终答案 | 在 prompt 中强调“结论必须基于工具返回” |
除了表里的,我再补几个只有踩过才知道的坑。
第一个坑:工具太多反而选不准。我一开始给模型挂了二十多个工具,结果它经常选错。后来精简到七八个高频工具,准确率明显上升。工具不是越多越好,能合并的合并,低频的收起来按需暴露。
第二个坑:observation 太长把模型带偏。有一次工具返回了几万字的原始数据,模型直接在里面迷路,忘了任务目标。后来我在工具层就做了截断和摘要,只返回关键片段,效果立竿见影。
第三个坑:system prompt 写太满。我试过把几十条规则全塞进 system prompt,结果模型顾此失彼。后来改成“核心规则放 system,具体约束放工具描述”,各司其职,反而更稳。
第四个坑:忽略失败路径。很多人只测成功路径,结果线上工具一报错,整个 Agent 就崩。我的做法是专门写一批“故障注入”测试:让工具随机返回错误、超时、空结果,看 Agent 能不能优雅处理。这个测试做完,稳定性会上一个台阶。
注意:Agent 的调试不能只看最终答案对不对,一定要看中间轨迹。最终答案对了但过程乱来,下次换个任务就会翻车。
6. 工具选型与自研的取舍:别为了框架而框架
市面上 Agent 框架不少,很多人纠结“用现成的还是自己写”。我的观点很明确:先想清楚你要的是快速验证还是长期可控。
如果你只是做个原型、验证一个想法,用现成框架没问题,它们帮你把循环、工具注册、上下文管理都封装好了,几天就能跑起来。但如果你要做的是要长期维护、要深度定制、要对每一步行为负责的系统,我强烈建议自研核心循环。原因有三:
第一,框架的抽象会挡住细节。当 Agent 行为异常时,你需要看到原始的 prompt、原始的模型输出、原始的工具返回。框架封装得越厚,你越难定位问题。
第二,框架的升级会带来不确定性。你依赖的框架改了个默认行为,你的 Agent 可能就变了。自研的话,核心逻辑在你手里,升级是可控的。
第三,大部分框架的核心逻辑其实不复杂。前面那两百行代码,已经覆盖了 80% 的常见需求。剩下的 20% 是业务特定的,框架也帮不了你。
当然,自研不等于什么都自己写。LLM 调用、向量检索、日志这些通用能力,用成熟库完全没问题。我的原则是:核心循环和上下文管理自己掌控,外围能力大胆用轮子。
具体到 LLM 选型,我的经验是:规划类、需要强推理的步骤用能力强的模型;摘要、格式转换这类简单任务用便宜快的小模型。一个任务里混用不同模型,成本能降不少,效果还不打折。这个“大小模型搭配”的策略,是我在实际项目里验证过最实用的省钱手段。
7. 我踩过的几个真实教训
最后分享几个我在实际项目里踩过的坑,都是文档里不会写的。
教训一:别在循环里做重试。早期我在工具执行失败时直接原地重试三次,结果遇到必然失败的调用(比如文件不存在),白白浪费三轮。正确做法是把失败信息返回给模型,让它决定是换参数、换工具还是放弃。模型比你想的会变通。
教训二:日志要带 trace id。一个任务可能触发几十次模型调用和工具调用,没有统一的 trace id,排查时根本串不起来。我从第二次项目开始,每个任务生成一个 id,所有日志都带上,排查效率翻倍。
教训三:给模型“退路”。当 Agent 确实完不成任务时,要允许它明确说“我做不到,原因是……”,而不是硬编一个答案。我在 prompt 里明确写了“如果信息不足,请直接说明缺少什么”,幻觉率明显下降。
教训四:测试用例要覆盖“坏输入”。用户可能给一个模糊到没法执行的目标,比如“帮我优化一下”。这时候 Agent 应该反问澄清,而不是瞎猜。我在循环开头加了一个“目标清晰度检查”,模糊目标直接让模型生成澄清问题返回给用户。
这套东西我从最初的一次裸调用,一路迭代到现在能稳定跑多步任务的 Harness,中间重构了三四次。每次重构都是因为遇到了新的边界情况。Agent 这个方向,没有一劳永逸的架构,只有不断逼近实际需求的迭代。你先跑起来一个最小版本,然后在真实任务里磨,比一开始就追求完美架构靠谱得多。