1. 从一次裸调用说起:为什么单次 LLM 调用撑不起一个 Agent
很多人第一次接触 Agent,脑子里想的其实还是"给大模型发一段话,它回一段话"。这个认知在 Demo 阶段没问题,但一旦你想让它自己查资料、自己改代码、自己跑测试、自己根据报错再改,单次调用立刻就不够用了。原因不复杂:单次调用的本质是无状态的一问一答,模型看不到上一轮自己干了什么,也没法在"想"和"做"之间来回切换。
我拿一个最朴素的场景举例。你让模型"帮我修复这个函数里的空指针问题",如果只做一次调用,模型只能基于你贴进去的代码猜一个改法,它没法去读文件、没法运行测试、没法看到运行结果再调整。它输出的就是一段"看起来对"的文本,至于对不对,全靠你自己验证。这就是单次调用的天花板——它只能生成,不能行动。
Agent 要解决的核心矛盾,就是让模型从"生成器"变成"执行器"。而执行器需要三样东西:一是能感知环境(读文件、查数据库、调接口),二是能改变环境(写文件、发请求、执行命令),三是能根据环境反馈决定下一步。这三样东西,单次调用一个都给不了。
所以从单次调用到 Agent,中间隔的不是"多调几次",而是一整套运行时(Runtime)。这个 Runtime 要负责循环、负责状态、负责工具调度、负责错误处理、负责把模型的自然语言输出翻译成可执行的动作。业内通常把这一层叫做 Harness,直译是"马具",很形象——模型是马,Harness 是缰绳和鞍,没有它,马跑得再快也拉不动车。
我见过不少团队在这一步走弯路:他们以为把 prompt 写长一点、把工具描述塞进去,模型就会自己"学会"用工具。实测下来,模型确实会输出类似{"tool": "read_file", "path": "a.py"}的文本,但谁来解析这段文本、谁来真正执行、执行完怎么把结果塞回去,这些全是空白。你不补上,模型输出的就只是一段 JSON 字符串,躺在对话记录里,什么都不会发生。
提示:判断一个项目是不是真 Agent,最简单的标准是——它有没有一个"执行-反馈-再决策"的闭环。没有闭环的,本质还是 prompt engineering,不是 Agent。
2. Harness Runtime 到底在管什么:四个绕不开的核心职责
把 Harness 拆开看,它其实在管四件事,缺一件都跑不起来。这四件事不是并列关系,而是有依赖顺序的,我按重要性排一下。
2.1 循环控制:Agent 的心跳
循环控制是 Runtime 的骨架。它要回答的问题是:什么时候让模型继续想,什么时候停下来。最朴素的实现就是一个while循环,每轮把当前对话历史喂给模型,拿到输出,判断是"要调工具"还是"要收尾",然后决定下一步。
但这里有个坑,新手特别容易踩:循环没有终止条件。模型可能陷入"调工具-看结果-再调同一个工具"的死循环,或者一直说"我再确认一下"却永远不给出最终答案。所以 Runtime 必须设置硬性上限,比如最大轮次(常见 10 到 50 轮)、最大 token 消耗、最大墙钟时间。我一般会同时设这三个,任何一个触顶就强制中断,把当前状态返回给用户。
另一个细节是循环的粒度。有些实现是"一轮只允许调一个工具",有些允许"一轮并行调多个工具"。前者简单可控,后者效率高但状态管理复杂。我的经验是:先做单工具串行,跑通之后再考虑并行。因为并行工具调用会带来结果顺序、依赖关系、部分失败等一堆问题,早期没必要给自己找麻烦。
2.2 状态管理:Agent 的记忆
状态管理决定了 Agent 能不能"记住自己干过什么"。这里的状态分两层:一层是对话历史,也就是模型看到的 messages 列表;另一层是环境状态,比如当前工作目录、已修改的文件、已执行命令的返回值。
对话历史的管理有个经典难题:上下文窗口有限,但任务可能很长。你不能把所有历史都塞进去,否则要么超限,要么成本爆炸。常见的处理策略有三种:滑动窗口(只保留最近 N 轮)、摘要压缩(把早期历史总结成一段话)、关键信息提取(只保留工具调用和结果,丢掉模型的思考过程)。我实测下来,摘要压缩 + 关键工具结果保留的组合最稳,既省 token 又不丢关键信息。
环境状态的管理则更考验工程能力。举个具体例子:Agent 修改了一个文件,然后要运行测试。如果 Runtime 不记录"这个文件已经被改过",那么当测试失败、Agent 想回滚时,它根本不知道该回滚到哪个版本。所以成熟的做法是给环境状态做快照,每轮关键操作前存一份,需要时能回退。这听起来像版本控制,实际上很多 Agent Runtime 内部确实集成了一个轻量级的版本管理。
2.3 工具调度:Agent 的手脚
工具调度是 Runtime 里最"接地气"的部分,因为它直接对接真实世界。工具的定义、注册、调用、结果解析,每一步都有讲究。
先说工具定义。给模型看的工具描述,本质是一段结构化文本,通常包含名称、功能说明、参数 schema。这里有个反直觉的经验:工具描述不是越详细越好。描述太长会挤占上下文,而且模型容易抓不住重点。我一般要求每个工具的描述控制在三句话以内,参数说明用一句话讲清楚"这个参数是什么、什么情况下填"。
再说调用解析。模型输出的工具调用请求,格式可能五花八门——有的用 JSON,有的用 XML 标签,有的干脆用自然语言"我想读一下 a.py"。Runtime 必须有一套容错解析机制,能处理格式不规范的输出。我的做法是:优先按约定格式解析,解析失败就退回到正则提取关键字段,再失败就把原始输出返回给模型,让它重新按格式输出。这个"重试"机制能救回大量因为格式问题卡住的轮次。
最后是结果回填。工具执行完的结果,要以什么形式塞回对话历史?直接塞原始输出(比如一坨日志)往往太长,需要做截断和摘要。我通常保留前 N 行和后 N 行,中间用省略号,同时标注"完整结果已保存到某处"。这样模型既能看到关键信息,又不会撑爆上下文。
2.4 错误处理:Agent 的免疫系统
错误处理是最容易被忽视、但上线后最要命的部分。Agent 会遇到的错误分几类:工具执行失败(文件不存在、命令报错)、模型输出异常(格式错误、幻觉调用不存在的工具)、Runtime 自身异常(超时、内存溢出)。
对工具执行失败,核心原则是把错误信息原样返回给模型,让它自己决定怎么办。模型看到"文件不存在",可能会换个路径重试,也可能告诉你"这个文件确实没有"。这比 Runtime 自己瞎猜要靠谱得多。
对模型输出异常,需要有限次重试 + 降级。比如模型连续三次输出格式错误,Runtime 就应该放弃解析,直接把"你输出格式不对,请按 X 格式重新输出"作为提示塞回去。如果还是不行,就终止任务并报告。
对 Runtime 自身异常,必须有兜底和清理。比如工具执行超时,要能杀掉子进程;任务中断,要能保存当前状态供下次恢复。这些是工程细节,但决定了 Agent 能不能在生产环境跑。
3. 架构选型:三种主流形态的取舍逻辑
搞清楚了 Runtime 管什么,接下来就是选型。市面上 Agent 架构大致分三种形态,各有各的适用场景,没有绝对优劣,只有合不合适。
3.1 单体式:一个进程管所有
单体式是最简单的形态:循环控制、状态管理、工具调度、错误处理全在一个进程里,工具以函数形式直接调用。它的优点是调试简单、延迟低、没有跨进程通信开销。缺点是扩展性差、隔离性弱——一个工具把进程搞崩了,整个 Agent 就挂了。
这种形态适合什么场景?我总结下来是:工具数量少(10 个以内)、任务时长短(几分钟内)、对延迟敏感的场景。比如一个专门做代码审查的 Agent,工具就是读文件、跑 lint、写评论这几个,单体式完全够用,而且响应快。
3.2 微服务式:工具独立部署
微服务式把每个工具或每组工具拆成独立服务,Runtime 通过 RPC 或 HTTP 调用。优点是隔离性好、可独立扩展、可复用——同一个文件操作服务可以被多个 Agent 共用。缺点是延迟高、运维复杂、状态一致性难保证。
这种形态适合工具数量多、需要跨团队复用、有安全隔离要求的场景。比如一个企业级 Agent 平台,文件操作、数据库查询、代码执行分别由不同团队维护,那就必须拆开。但要注意,拆开之后每次工具调用都要走网络,一轮任务下来可能几十次调用,延迟累积起来很可观。所以微服务式通常要配合批量调用和连接池来优化。
3.3 混合式:核心循环单体,重工具隔离
混合式是我个人最推荐的形态,也是目前多数成熟产品的选择。它的思路是:循环控制、状态管理这些高频、轻量的部分放在单体进程里,保证速度;代码执行、浏览器操作这些重、危险、可能崩溃的工具放到独立沙箱里,保证安全。
这样既拿到了单体的低延迟,又拿到了隔离的安全性。代价是架构复杂度上升,需要设计好单体进程和沙箱之间的通信协议。但相比全微服务,混合式的通信次数少得多,只有真正危险的操作才走隔离通道。
下面这张表是我对三种形态的对比,供选型时参考:
| 维度 | 单体式 | 微服务式 | 混合式 |
|---|---|---|---|
| 实现复杂度 | 低 | 高 | 中 |
| 调用延迟 | 低 | 高 | 中 |
| 隔离性 | 弱 | 强 | 强(重工具) |
| 扩展性 | 差 | 好 | 中 |
| 适用工具数 | 少 | 多 | 中到多 |
| 典型场景 | 专用小 Agent | 企业平台 | 通用 Agent 产品 |
选型时我的建议是:从单体式起步,遇到瓶颈再演进。不要一上来就搞微服务,那是给自己挖坑。很多团队在 Demo 阶段就设计了一套复杂的分布式架构,结果发现根本跑不到需要扩展的量级,白白浪费了开发时间。
4. 从零搭一个最小可用 Runtime:关键代码与决策点
光讲架构太虚,我直接给一个最小可用 Runtime 的实现思路。语言用 Python,因为生态最全,但逻辑换成任何语言都成立。
4.1 主循环的骨架
主循环的核心就是一个带终止条件的 while。伪代码大概长这样:
def run_agent(task, max_turns=20, max_tokens=100000): messages = [{"role": "user", "content": task}] total_tokens = 0 for turn in range(max_turns): response = call_llm(messages) total_tokens += response.usage.total_tokens if total_tokens > max_tokens: return "任务因 token 超限中断", messages messages.append(response.message) if response.is_final_answer: return response.content, messages tool_calls = parse_tool_calls(response) for call in tool_calls: result = execute_tool(call) messages.append({"role": "tool", "content": truncate(result)}) return "任务因轮次超限中断", messages这段代码有几个决策点值得展开。第一,终止条件用轮次和 token 双保险,因为单看轮次可能每轮消耗巨大,单看 token 可能轮次太多导致延迟高。第二,工具结果要截断,truncate函数我一般保留头尾各 50 行。第三,最终答案和工具调用要能区分,这靠模型输出里的标记位,或者靠"有没有 tool_calls 字段"来判断。
4.2 工具注册与 schema 设计
工具注册我推荐用装饰器模式,写起来清爽:
TOOLS = {} def tool(name, description, params): def decorator(func): TOOLS[name] = { "name": name, "description": description, "parameters": params, "func": func, } return func return decorator @tool( name="read_file", description="读取指定路径的文件内容,返回文本。", params={"path": {"type": "string", "description": "文件相对路径"}} ) def read_file(path): with open(path, "r", encoding="utf-8") as f: return f.read()这里的关键是schema 要简洁。我见过有人把每个参数写成一大段说明,结果模型反而抓不住重点。参数描述一句话讲清楚"是什么、什么时候填"就够了。另外,工具名要有语义,read_file比rf好,search_code比sc好,模型对语义化名称的理解准确率明显更高。
4.3 上下文压缩的具体做法
上下文压缩是长任务能不能跑完的关键。我的做法是分三步:第一步,工具结果只保留摘要,比如文件内容只留前 100 行加"共 N 行";第二步,超过阈值的历史做摘要,把最早的若干轮对话交给模型总结成一段话;第三步,保留最近 K 轮完整历史,保证模型对当前状态有清晰认知。
具体阈值怎么定?我一般按 token 算:当历史超过模型上下文窗口的 60% 时触发压缩,压缩到 40% 以下。留 20% 的缓冲给当前轮的工具结果和模型输出。这个比例是实测出来的,太激进会丢信息,太保守会频繁压缩影响效率。
注意:压缩历史时,工具调用的参数和结果不要丢,只压缩模型的思考过程。因为参数和结果是事实,思考过程是冗余。丢了事实,模型会重复劳动;丢了思考,模型照样能干活。
4.4 错误重试的边界
错误重试要有边界,否则会陷入"重试-失败-再重试"的泥潭。我的策略是:工具执行失败重试 1 次,模型格式错误重试 2 次,Runtime 异常不重试直接上报。工具失败重试一次是因为很多失败是瞬时的(比如网络抖动),重试一次能救回来;格式错误重试两次是因为模型有时需要"提醒"一下才能改对;Runtime 异常通常意味着代码 bug,重试也没用,不如早点暴露。
重试时要注意退避。连续重试之间加个短延迟(比如 1 秒),避免把下游服务打爆。这个细节在小规模测试时看不出来,一上量就是事故。
5. 实测中那些文档不会写的坑
前面讲的都是"应该怎么做",这一节讲"实际做的时候会怎么翻车"。这些坑我基本都踩过,写出来给后来人省点时间。
5.1 模型"假装"调用了工具
这是最隐蔽的坑。模型有时会输出一段看起来像工具调用、但格式不对的文本,比如把 JSON 写在 markdown 代码块里,或者参数名拼错。如果你的解析器不够健壮,就会解析失败,然后模型以为工具执行了,继续往下走,结果全是错的。
我的应对是:解析失败必须显式告诉模型。不要静默失败,也不要自己猜。把"你的工具调用格式不对,请按 X 格式重新输出"塞回去,让模型自己修。实测下来,90% 的格式问题一次提醒就能解决。
5.2 工具结果太长撑爆上下文
这个坑在"读大文件"或"跑长命令"时特别常见。模型读了一个 5000 行的文件,结果直接塞进上下文,下一轮就超限了。更糟的是,超限之后模型可能只看到文件的前半部分,做出错误判断。
解决办法前面提过,就是截断 + 摘要。但截断有个细节:要保留头尾。因为文件开头通常是定义和导入,结尾通常是关键逻辑,中间大段是重复的样板代码。只留头或只留尾都会丢关键信息。
5.3 循环终止条件形同虚设
我见过一个实现,设了最大轮次 50,结果 Agent 在第 49 轮还在"我再确认一下",第 50 轮被强制中断,任务没完成。问题出在终止条件只看轮次,不看任务是否收敛。
更好的做法是加一个收敛检测:如果连续 N 轮模型没有调用任何工具、也没有产出新信息,就判定为"卡住",主动中断并报告。这个 N 一般设 2 到 3。另外,可以在 prompt 里明确告诉模型"如果你已经完成任务,请直接输出最终答案,不要再调用工具",减少模型"过度谨慎"的情况。
5.4 工具之间的隐式依赖
这个坑在多工具场景下特别致命。比如 Agent 先调create_file创建文件,再调write_file写入内容。如果create_file失败了但 Runtime 没检查返回值,write_file就会报"文件不存在",然后 Agent 可能陷入困惑。
解决办法是工具结果要结构化,明确包含成功/失败标志。Runtime 在调度时,如果前一个工具失败,可以选择跳过依赖它的后续工具,或者把失败信息返回给模型让它重新规划。这需要 Runtime 对工具之间的依赖关系有一定理解,简单做法是让模型在调用时显式声明依赖,复杂做法是 Runtime 自己维护一个依赖图。
5.5 沙箱逃逸与资源耗尽
如果 Agent 能执行代码,那安全就是头等大事。我见过最离谱的案例是 Agent 执行了一段while True的代码,把整个进程 CPU 占满。还有 Agent 试图访问工作目录之外的文件。
应对措施分两层:资源限制和权限隔离。资源限制包括 CPU 时间上限、内存上限、磁盘写入上限,超限直接杀进程。权限隔离包括文件系统只读挂载、网络访问白名单、禁止提权操作。这些在容器化环境里相对好做,裸机部署就要格外小心。
提示:任何允许 Agent 执行任意代码的场景,都必须假设"模型会写出恶意代码"。这不是危言耸听,而是安全设计的基本假设。
6. 演进路线:从能跑到好用要补哪些能力
一个能跑的 Runtime 和一个好用的 Runtime,中间差着好几层能力。我按优先级排一下演进路线,供参考。
6.1 可观测性:先能看见,才能优化
第一件要补的是可观测性。具体包括:每轮对话的完整记录(输入、输出、工具调用、结果)、每轮耗时和 token 消耗、工具调用的成功率。这些数据不上报,你根本不知道 Agent 卡在哪、贵在哪。
我的做法是给 Runtime 加一个事件总线,每个关键节点发一个事件,事件落到日志或数据库。分析时按任务 ID 聚合,就能看到完整执行链路。这个投入在早期看起来"没必要",但一旦任务失败率上升,没有它你只能靠猜。
6.2 人工介入:该停的时候要能停
有些任务 Agent 自己搞不定,需要人介入。比如涉及删除文件、发送邮件这类不可逆操作,应该暂停并请求确认。这需要在 Runtime 里加一个"审批点"机制:工具执行前检查是否在审批清单里,是则挂起任务,等人工确认后再继续。
审批点的设计要注意不能太频繁,否则人会被烦死。我的经验是只对"不可逆 + 高影响"的操作设审批,比如删除、发送、支付。读文件、查数据这类只读操作不需要审批。
6.3 多 Agent 协作:什么时候需要,什么时候是过度设计
多 Agent 协作是热门话题,但我要泼盆冷水:大多数场景不需要多 Agent。一个设计良好的单 Agent 加一套好工具,能解决 80% 的问题。多 Agent 带来的通信开销、状态同步、冲突解决,往往得不偿失。
真正需要多 Agent 的场景是任务可以清晰拆分且子任务之间弱耦合。比如一个"写报告"任务,可以拆成"查资料 Agent"和"写稿 Agent",前者输出资料,后者基于资料写稿。但如果子任务之间需要频繁交互,多 Agent 反而更慢。
如果确实要做多 Agent,我建议从主从模式起步:一个主 Agent 负责规划和调度,若干从 Agent 负责执行具体子任务。主从模式的控制流清晰,比平等协作模式好调试得多。
6.4 评测体系:没有度量就没有改进
最后一块是评测。Agent 的效果不能靠"感觉还行"来判断,必须有量化指标。我一般关注三个:任务完成率(成功完成的任务占比)、平均轮次(完成一个任务平均要几轮)、平均成本(每个任务的 token 和工具调用成本)。
评测集要覆盖典型任务和边界任务。典型任务验证基本能力,边界任务验证鲁棒性。边界任务包括:工具失败、输入模糊、任务不可能完成等。这些场景下 Agent 的表现,往往比典型任务更能反映系统质量。
7. 一些个人体会
搭 Agent Runtime 这件事,我最大的体会是:难点不在模型,在工程。模型能力每年都在涨,但 Runtime 的循环控制、状态管理、错误处理这些,是实打实的工程活,没有捷径。
另一个体会是不要过早优化。我见过太多团队在 Demo 阶段就设计了一套复杂的分布式架构,结果发现根本跑不到需要扩展的量级。正确的做法是先用单体式跑通,遇到具体瓶颈再针对性优化。瓶颈可能是延迟、可能是隔离、可能是扩展性,不同瓶颈对应不同方案,提前猜往往猜错。
还有一点:工具的质量比数量重要。与其给 Agent 塞 50 个半成品工具,不如把 10 个核心工具做扎实。工具的描述要准、参数要清晰、错误要友好、结果要结构化。这四点做到,Agent 的成功率会有肉眼可见的提升。
最后,保持对模型输出的怀疑。模型会自信地输出错误的东西,会假装调用了工具,会在该停的时候不停。Runtime 的职责之一,就是当模型不靠谱时的最后一道防线。这道防线做得好不好,直接决定了 Agent 是"玩具"还是"工具"。