1. 从一次裸调用说起:为什么单次 LLM 调用撑不起一个 Agent
很多人第一次接触 AI Agent,脑子里想的其实还是"给大模型发个 prompt,拿回一段文本"。这个认知在 Demo 阶段没问题,但一旦你想让它真正干活——比如自动整理文件、跑一段数据分析、在多个工具之间来回切换——你会发现单次调用根本不够用。原因很直接:LLM 本身是无状态的、单轮的、没有记忆也没有手脚的。它只能根据你这次塞进去的上下文吐出一段 token,至于这段 token 是对是错、下一步该干嘛、要不要调用工具,它自己管不了。
我刚开始搭 Agent 的时候,就是用一个 while 循环包着 API 调用,把历史消息拼起来反复喂进去。跑简单任务还行,稍微复杂一点就崩:模型开始重复输出、忘记之前做过什么、工具调用参数格式错乱、上下文越滚越长最后直接超 token 上限。这些问题不是模型不行,而是架构缺了一层"运行时"。所谓 Harness Runtime,说白了就是套在 LLM 外面的那层骨架,负责循环控制、状态管理、工具调度、错误恢复、上下文裁剪。没有这层骨架,你手里只有一个会说话的模型,不是一个能干活的 Agent。
这篇文章我想把从"一次 LLM 调用"到"完整 Harness Runtime"的演进路径讲清楚,包括每一层为什么需要、怎么实现、踩过哪些坑。适合已经能跑通 API 调用、但想让 Agent 真正稳定干活的中级开发者,也适合正在做架构选型、纠结要不要上框架的人。核心关键词会围绕AI Agent、LLM、Harness Runtime、Sandbox、架构选型展开,不堆概念,尽量给能直接抄的方案。
先说结论:Agent 的复杂度不是线性增长的,而是每加一个能力就多一类失败模式。你加工具调用,就多了参数解析失败;你加多轮循环,就多了死循环和上下文爆炸;你加沙箱执行,就多了资源隔离和超时处理。所以架构选型的本质,是你愿意为哪些失败模式提前买单。
2. 拆解 Agent 的四层能力:从裸调用到 Harness 的演进阶梯
2.1 第一层:裸 LLM 调用,只有输入输出
最原始的一层就是prompt in, text out。你调一次接口,拿回一段字符串,任务结束。这一层的能力边界非常清晰:它只能做"一次性生成"类任务,比如翻译、摘要、改写、单轮问答。任何需要"根据结果决定下一步"的场景,这一层都做不了。
这一层的典型代码长这样:
def call_llm(prompt: str) -> str: resp = client.chat.completions.create( model="your-model", messages=[{"role": "user", "content": prompt}] ) return resp.choices[0].message.content看起来简单,但这里已经埋了第一个坑:你没有把 system prompt 和 user prompt 分开。很多人图省事全塞进一个字符串,结果后面想加工具定义、加历史消息的时候,整个 prompt 结构乱成一团。我的建议是从第一天起就用标准的 messages 数组结构,哪怕现在只有一条 user 消息。这个习惯能让你后面平滑升级到多轮,不用推倒重来。
2.2 第二层:带工具调用的单轮 Agent
第二层开始引入Function Calling / Tool Use。模型不再只输出文本,而是可以输出一个结构化的"我要调用某个工具,参数是这些"的意图。你的代码负责解析这个意图、真正执行工具、把结果再喂回去。
这一层的关键在于工具定义的 schema 设计。工具描述写得好不好,直接决定模型会不会用错。我见过太多人把工具描述写成"查询数据",模型根本不知道查什么数据、参数怎么填。正确的做法是把工具当成给一个新人看的 API 文档来写:说清楚它干什么、什么时候用、每个参数什么含义、有没有默认值。
{ "name": "read_file", "description": "读取指定路径的文本文件内容。当需要查看文件内容时使用。", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "文件的绝对路径,例如 /home/user/data.txt" } }, "required": ["path"] } }这一层能跑通"模型决定调哪个工具"的闭环,但还是一次性的:调完工具拿到结果,再生成一次回答就结束了。它做不了"调完发现不对,再调另一个工具"这种多步推理。
2.3 第三层:多轮循环,Agent 开始有"手"
第三层是质变。你加一个循环:模型输出工具调用 → 执行 → 结果回填 → 再问模型 → 直到模型输出最终答案或达到最大轮数。这时候 Agent 才真正有了"手脚",能连续做多步操作。
但循环一加,问题全来了。我列一下最常见的几类:
| 失败模式 | 表现 | 根因 |
|---|---|---|
| 死循环 | 反复调同一个工具 | 模型没拿到有效反馈,或工具一直报错 |
| 上下文爆炸 | token 数飙升,成本失控 | 每轮都把完整历史塞进去,从不裁剪 |
| 参数漂移 | 工具参数格式越来越乱 | 历史里混入了错误的调用示例 |
| 提前终止 | 任务没做完就输出答案 | 缺少"任务完成"的判定信号 |
这一层的核心工作不是写循环,而是设计循环的退出条件和状态管理。最大轮数只是兜底,真正重要的是让模型知道"任务完成了没有"。我的做法是在 system prompt 里明确要求:任务完成时必须调用一个finish工具,或者输出特定标记。这样退出条件就从"模型不调工具了"变成"模型显式声明完成",可靠得多。
2.4 第四层:Harness Runtime,把循环变成可控系统
第四层就是标题里的Harness Runtime。它不只是循环,而是一整套围绕 LLM 的运行时环境,包含:状态机、上下文管理器、工具注册表、沙箱执行器、错误恢复策略、可观测性埋点。到了这一层,你写的其实是一个"操作系统",LLM 只是里面的一个决策组件。
为什么叫 Harness(挽具/骨架)?因为它像马具一样,把 LLM 这匹"野马"约束在可控的轨道上。模型负责决策,Harness 负责执行、约束、记录、恢复。这个分工一旦理清,架构就顺了。
3. Harness Runtime 的五个核心模块与实现要点
3.1 状态机:Agent 到底"现在处于什么状态"
裸循环最大的问题是没有显式状态。你不知道 Agent 是在"思考"、"等工具结果"还是"准备收尾"。Harness 的第一件事就是把状态显式化。一个最小可用的状态集合是:
IDLE:等待输入THINKING:模型正在生成TOOL_CALLING:正在执行工具OBSERVING:工具结果已回填,等待模型下一步FINISHED:任务完成FAILED:不可恢复错误
状态机的价值在于每个状态转换点都是你可以插入检查、日志、超时控制的地方。比如在TOOL_CALLING状态加一个超时,超过 30 秒强制中断并回填一个错误结果给模型,让它自己决定换策略。这比整个循环卡死强太多。
class AgentState(Enum): IDLE = "idle" THINKING = "thinking" TOOL_CALLING = "tool_calling" OBSERVING = "observing" FINISHED = "finished" FAILED = "failed" def step(self, state): if state == AgentState.THINKING: return self._call_model() elif state == AgentState.TOOL_CALLING: return self._execute_tools() # ...3.2 上下文管理器:别让历史消息无限膨胀
这是最容易被忽视、但最影响成本和稳定性的模块。多轮循环里,如果你每轮都把完整历史塞进去,token 消耗是平方级增长的。跑十几轮,成本就失控了。
上下文管理要做三件事:裁剪、摘要、优先级保留。我的实践是分层处理:
- system prompt 永远保留,这是行为准则,不能丢。
- 最近 N 轮完整保留,保证短期记忆连贯。
- 更早的历史做摘要压缩,用一次额外的 LLM 调用把旧对话压成一段摘要。
- 工具结果按需保留,大的工具输出(比如读了一个大文件)只保留关键片段,完整内容存到外部,需要时再取。
注意:摘要压缩本身也要花 token,所以别每轮都压。我的经验是当历史超过上下文窗口的 60% 时触发一次压缩,压到 30% 左右,留出余量。
这里有个反直觉的点:不是保留越多历史越好。历史里如果混入了大量失败的工具调用记录,模型反而会被带偏,倾向于重复错误。所以裁剪时优先丢弃"失败且已重试成功"的记录。
3.3 工具注册表:让工具可插拔、可校验
工具多了之后,硬编码 if-else 分发就是灾难。Harness 需要一个工具注册表,每个工具注册时带上:名称、描述、参数 schema、执行函数、超时时间、是否需要沙箱。
class ToolRegistry: def __init__(self): self._tools = {} def register(self, name, schema, func, timeout=30, sandbox=False): self._tools[name] = { "schema": schema, "func": func, "timeout": timeout, "sandbox": sandbox } def execute(self, name, args): tool = self._tools.get(name) if not tool: return {"error": f"未知工具: {name}"} # 参数校验、超时控制、沙箱分发 ...注册表的好处是参数校验可以统一做。模型给的参数经常缺字段、类型不对、路径不存在。与其让工具函数自己崩,不如在注册表层做一次 schema 校验,把错误信息结构化地回填给模型,让它自己修正。这比抛异常中断整个循环优雅得多。
3.4 Sandbox:为什么执行代码必须隔离
一旦 Agent 能执行代码或系统命令,沙箱就不是可选项,而是必选项。原因很简单:模型生成的代码你无法完全信任,它可能删文件、可能死循环、可能占满内存。Sandbox 要解决的是资源隔离和权限约束。
沙箱的实现层次有好几种,从轻到重:
| 方案 | 隔离级别 | 适用场景 | 代价 |
|---|---|---|---|
| 子进程 + 超时 | 弱 | 可信代码、快速原型 | 低 |
| 容器(Docker) | 中 | 一般生产环境 | 中 |
| 微虚拟机(如 Firecracker) | 强 | 多租户、不可信代码 | 高 |
| 语言级沙箱 | 中 | 特定语言 | 中 |
我的建议是:原型阶段用子进程加超时,生产环境至少上容器。容器里限制 CPU、内存、网络、文件系统挂载,把 Agent 的执行环境和一个一次性工作区绑定。任务结束,容器销毁,工作区清理。这样即使模型写出破坏性代码,影响范围也被限制在容器内。
提示:沙箱里跑代码,一定要设墙钟超时而不只是 CPU 超时。有些代码会 sleep 或者等 IO,CPU 占用很低但就是不返回,只有墙钟超时能救你。
3.5 错误恢复:Agent 的"自主容错"从哪来
标题热词里有个"识的 LLM 智能体自主容错控制",说的其实就是这块。Agent 跑长任务,出错是常态:工具超时、参数错误、模型输出格式不对、外部 API 挂了。Harness 的错误恢复策略决定了 Agent 是"一崩到底"还是"自己爬起来"。
我的分层恢复策略:
- 可重试错误(网络抖动、临时超时):自动重试 2-3 次,指数退避。
- 可修正错误(参数错误、格式错误):把错误信息回填给模型,让它重新生成。
- 可降级错误(某工具不可用):告诉模型这个工具挂了,让它换方案。
- 不可恢复错误(权限拒绝、资源耗尽):终止并报告。
关键在于错误信息要结构化地喂回模型,而不是抛个异常就完事。模型看到"参数 path 缺失"比看到"KeyError"有用得多。这就是"自主容错"的本质:不是模型自己会容错,而是 Harness 把错误翻译成模型能理解的语言,让模型有机会自我修正。
4. 架构选型:什么时候自己写,什么时候上框架
4.1 自研 Harness 的临界点在哪
不是所有项目都需要自己写 Harness。我的判断标准是三条:
- 任务是否需要多步工具调用?如果只是单轮生成,别折腾。
- 是否需要执行不可信代码?如果需要,沙箱这块自研成本高,优先考虑成熟方案。
- 是否需要深度定制上下文策略?如果标准框架的上下文管理够用,就别自己造。
三条里中两条以上,才值得自研。否则用现成框架更快。自研的代价主要在可观测性和边界情况处理上,这两块最耗时间,也最容易在 Demo 阶段被忽略、上线后集中爆发。
4.2 框架选型的三个维度
选框架我一般看三个维度:控制粒度、沙箱能力、可观测性。
控制粒度指的是你能不能干预每一轮循环、每一次工具调用。有些框架封装太深,你想在工具执行前加个校验都插不进去,这种就不适合复杂任务。沙箱能力看它是否内置隔离执行环境,还是只给你个接口自己接。可观测性看它有没有 trace、有没有每步的 token 统计、能不能回放。
我的经验:框架选型别只看功能列表,要看它的"逃生舱"设计。也就是当框架的默认行为不满足你时,你能不能绕过它自己实现。没有逃生舱的框架,用起来迟早被卡死。
4.3 一个务实的混合方案
实际项目里,我很少纯自研或纯用框架,更多是混合:用框架做循环和工具调度,自己写上下文管理和沙箱层。因为循环和工具调度是通用逻辑,框架做得比你好;而上下文策略和沙箱约束是跟业务强相关的,自己写更可控。
具体做法是:框架负责THINKING → TOOL_CALLING → OBSERVING的主循环,你在工具执行层挂一个自己的沙箱执行器,在每轮开始前挂一个自己的上下文裁剪钩子。这样既省了造轮子的时间,又保住了关键环节的控制权。
5. 实测中的坑:那些文档不会告诉你的细节
5.1 工具结果太长,把上下文撑爆
这是我最常踩的坑。Agent 读了一个大文件或者查了一大段数据,工具结果几万 token,直接回填进去,下一轮就超限了。解决办法是工具结果在回填前先做截断和摘要。我的做法是给每个工具设一个结果长度上限,超过就截断,并在末尾加一句"结果已截断,如需完整内容请用分页参数重新查询"。这样模型知道信息不全,会主动去取更多,而不是基于残缺信息瞎猜。
5.2 模型"假装"调用了工具
有些模型在工具调用格式上不稳定,会输出一段看起来像工具调用、但实际是普通文本的内容。你的解析器如果只认结构化字段,就会漏掉;如果太宽松,又会误判。我的处理是双重校验:既看结构化字段,也看文本里有没有工具名和参数模式,两者不一致时以结构化字段为准,并把异常记录下来。跑一段时间后你会发现某些模型在特定 prompt 下特别容易出这个问题,针对性调整 system prompt 就能缓解。
5.3 沙箱里的路径问题
沙箱执行代码时,路径是最容易出错的。模型生成的代码里写的路径,可能是它"以为"的路径,跟沙箱实际挂载的路径对不上。我的做法是在 system prompt 里明确告诉模型工作目录是什么,并且在沙箱里把工作目录固定挂载到一个已知位置。工具描述里也写清楚"所有路径相对于工作目录"。这三处对齐之后,路径错误率大幅下降。
5.4 超时设置的两难
超时设太短,正常的长任务被误杀;设太长,卡死的任务拖垮整个流程。我的经验是分级超时:工具调用给 30 秒,代码执行给 60 秒,模型生成给 120 秒。并且超时后不是直接失败,而是回填一个"执行超时"的结果给模型,让它决定是重试、换方案还是放弃。这样超时从"致命错误"变成了"一个可处理的事件"。
5.5 成本失控的隐形杀手
除了上下文膨胀,还有一个隐形杀手是重试放大。一个工具失败,模型重试,重试又失败,再重试……每轮都是一次完整的模型调用。如果不设重试上限,成本会悄悄翻好几倍。我的做法是给每个工具设独立的失败计数,同一个工具连续失败 3 次就标记为"暂时不可用",在后续轮次里从工具列表里摘掉,逼模型换方案。
6. 把 Harness 跑稳之后,我总结的几条经验
搭 Harness 这件事,我前后重构过三次,从最早的裸循环到现在的状态机加沙箱,最大的体会是:Agent 的稳定性不取决于模型多强,而取决于 Harness 把多少不确定性收进了可控范围。模型再聪明,你给它一个会死循环、会爆上下文、会执行危险代码的环境,它也干不好活。
几条我反复验证过的经验:第一,状态显式化永远不亏,哪怕一开始只有三个状态,也比隐式循环好调试。第二,错误信息要翻译成模型能懂的话,这是自主容错的前提。第三,沙箱和超时是底线,不是优化项,能执行代码就必须隔离。第四,上下文管理要主动做,别等爆了再救。第五,留好逃生舱,无论用不用框架,关键环节的控制权要握在自己手里。
这套 Harness 跑稳之后,你会发现换模型、加工具、改任务都变得很轻松,因为变化被隔离在各自的模块里。这大概就是从"调 API"到"做系统"的分水岭。后面如果要做多 Agent 协作,其实也是在这套 Harness 之上再加一层调度,底层逻辑是通的。