做某跨平台系统的 Agent 化改造时,团队争论最多的一个问题是:代码到底该写在哪个层级。有人觉得每个任务不就是调一次大模型,把 prompt 写长点就完事;有人坚持要上完整的 Harness Runtime,把循环、工具、记忆、护栏全做成基础设施。两边看着都合理,可真正动手以后会发现,从“一次 LLM 调用”到“一个能稳定跑任务的 AI Agent”之间,隔着的远不是几行代码,而是一整套架构取舍。
这篇文章就围绕这个决策过程展开。我会先帮你搞清楚“你现在到底卡在哪一层”,再讲清楚“什么情况下该换架构、换成什么”,最后把 Harness Runtime 的核心实现拆开,附上一份可以直接参考的落地清单和踩坑记录。适合正在做 LLM 应用集成、想把单点调用升级成完整 Agent 服务的开发者或架构师,也适合那些刚开始研究 Agent 编排、被各种概念绕晕的人。
1. 先搞清楚:你遇到的问题属于哪一层
1.1 单次 LLM 调用能做什么,不能做什么
单次 LLM 调用的本质很简单:你构造一段 prompt,发给模型,模型返回一段文本。整个链路里没有循环、没有工具、没有记忆、没有退出条件,就是一个“输入—输出”的封闭过程。
这非常像打电话问路。对方告诉你“往东走,第二个路口右转”,你就先信了,可走着走着发现前面修路,这条路还能不能走、要不要换一条,电话那头的人并不知道。你只能到了路口再打一次电话,再听一段新的指示。单次调用能解决的任务,天然是那种有明确边界、一步到位的事:文本分类、信息抽取、单轮客服回复、敏感词识别,这些场景里 prompt 写得够好,模型表现就够用,不值得为它们搭一套复杂的 Agent 架构。
但现实中的任务往往长这样:“帮我把这份合同从头过一遍,找出所有和财务条款冲突的地方,并且草拟三个可替换的修改方案。”这种任务需要先读合同,再翻财务条款,还要对比历史审批记录,最后才能生成建议。整个过程至少分成“拆解需求、检索资料、推理判断、产出结果”四步,每一步都可能需要访问不同的数据源。单次 LLM 调用在这个任务面前会非常吃力,因为模型既没有能力去调接口,也没有办法在中间一步发现“上下文不够,需要再查一份材料”时主动停下并执行查询。
所以判断自己该不该往上走,第一条判断标准就是:你的任务能不能在“一次输入、一次输出”的封闭链路里完成。如果你发现自己已经在 prompt 里塞了大段示例数据,让模型“假装”能访问某个系统,或者你已经开始在业务代码里根据模型的输出反复回填、再查、再喂,那就说明你已经不在单次调用的舒适区里了。
1.2 从“一次调用”到“Agent 回路”的四个关键跃迁
从单次 LLM 调用升级到 Agent,本质上不是换一个更大的模型,而是给整个过程加四个东西。
第一个是循环。Agent 必须能在“思考—行动—观察”之间反复转圈。模型先看到任务,决定调用某个工具,工具返回结果,模型再基于新结果决定下一步。这个循环是 Agent 和普通 API 封装最核心的区别,也是代码复杂度真正开始上升的地方。
第二个是工具。没有工具的模型只能输出文字,有了工具,模型才能执行搜索、查数据库、发消息、更新文件。这里的难点不在于“写一个函数”,而在于“让模型在合适的时候选对合适的函数”。函数描述写得含糊,模型就不会用;参数定义得不严格,模型就会传错值。很多新手 Agent 项目跑崩,不是死在模型智商上,而是死在工具层没有做扎实。
第三个是状态。多轮任务必须有记忆,至少要记住用户的目标是什么、已经完成了哪几步、剩下哪些待办。状态管理如果做得太随意,Agent 会很快迷失方向,出现“明明已经查到了订单号,下一步又让用户重新输入一遍”这种让人抓狂的问题。
第四个是控制。循环一旦跑起来,就得有约束:最多跑多少步、哪些工具不能碰、工具执行超时怎么办、什么条件下必须停下来请示人工。没有控制层,Agent 就会变成一台失控的机器,在一个错误分支里反复横跳。
这四样东西加在一起,就是你常听到的 Harness Runtime。Harness 这个词在工程界并不新鲜,测试圈很早就有 Test Harness 的说法,说白了就是“用一套夹具把被测对象架起来,让它在一个受控环境里运行”。Agent Harness 也是同一个思路:给模型和工具之间搭一个架子,让模型不掉下去、不越界、不乱跑,同时还能稳定地往前走。
2. 架构选型:别一上来就 All in Runtime
2.1 用一个三维坐标判断复杂度
我做选型的时候喜欢用一个三维模型来判断当前任务的复杂度,比拍脑袋靠谱很多。
第一个维度是状态复杂度。你的 Agent 需要跨多少轮对话、多少个子任务才能完成目标?用户问一句“帮我查一下上月所有订单的异常情况,然后发个汇总给我”,这里其实只有两轮:查数据、发汇总。但“帮我每天跟踪这批订单,发现异常就告警,下班前给我一份日报”就是跨天的长时任务,状态复杂度直接翻倍。
第二个维度是工具数量。只调一个搜索接口,和需要同时调订单系统、库存系统、物流系统和通知服务,完全不是一个量级。工具一多,模型每次都面临“这次该调哪个”的决策,而工具之间的调用顺序还可能影响后面的结果。
第三个维度是循环深度。有的任务,模型调一次工具、回答一次就结束了,这就属于浅循环;有的任务要让模型反复试错、根据中间结果调整策略,这就是深循环。深循环对错误容忍度和上下文管理的要求都更高。
把这三维画成一个坐标,就能很直观地看你到底需要什么架构:三个维度都很低,用单次调用就可以;状态复杂或流程固定但工具调用链条很明确,适合工作流式编排;三个维度里有两个以上偏高,而且模型需要在运行过程中动态决定下一步,那基本就跑不掉要上 Runtime。
2.2 三种架构形态的对比与取舍
我把常见方案归成三种,方便直接对号入座。
第一种是裸调用加业务循环。你在业务代码里写一个 for 循环,模型的输出里包含某个标记,就用正则抽出来,调一个函数,再把结果拼回去继续请求模型。这种方案适合工具数量极少、流程几乎不变的原型,或者做概念验证时快速验证“模型到底能不能做这件事”。优点是零额外架构成本,缺点是逻辑全散在业务代码里,循环次数一多代码就乱成一锅粥,基本不具备扩展性。
第二种是工作流编排,也就是状态机思路。任务拆成固定的节点,每个节点有明确的输入输出,节点之间用条件分支连接。比如“先查用户身份,再查订单,若订单异常走告警分支,否则走正常通知分支”。这种方案非常适合流程确定的场景,因为它可控、可解释、出错容易定位。缺点是它不具备真正的动态决策能力:节点是你在写代码时定死的,模型办不到的只是“临时发现需要额外信息,自己加一步”,它只能沿着预设路径走。
第三种是完整的 Harness Runtime。模型负责动态决策,Runtime 负责把决策变成行动,再观察行动结果回传给模型。这种方案最灵活,能承载高度不确定的任务,但代价是要处理的工程问题最多,包括上下文压缩、工具并发、状态持久化、安全审计。我的建议很直接:能用工作流编排解决的,不要上 Runtime;Runtime 是用来解决“流程本身无法预先确定”这类问题的。
2.3 我判断“该上 Runtime”的几个信号
在我实际带项目的经验里,出现下面任何一个信号,就得认真考虑上 Runtime,而不是继续在 prompt 和业务代码里打补丁。
信号一:工具数量超过十个,并且模型经常需要“先调 A 工具拿到中间结果,再决定调 B 还是 C”。这时候你用 if-else 在业务代码里写分支,会写到怀疑人生。
信号二:任务要跨多个会话,或者需要同时处理多个独立目标。比如一个 Agent 同时跟踪几个项目进度,还要按优先级调整计划,这已经不是“一轮问答”能覆盖的,必须要有可靠的状态管理。
信号三:每次跑完一个长任务,都要花很大力气复盘“它到底干了什么”。Agent 一旦出错,你不能像查普通函数一样打印堆栈,因为每一步都是模型决定的。没有 Runtime 做 step 级日志,复盘基本靠猜,这是上生产环境的大忌。
信号四:需要精细控制 Agent 的边界行为,比如禁止访问某些系统、限制调用频率、超时自动熔断。这些能力如果散落在各个业务函数里,根本没法统一管理,只有放到 Runtime 层才能做到全局生效。
这里补充一句我的经验:刚起步时不要一步到位追求大而全的 Runtime。我见过很多开发者在第一个 Demo 里就引入了消息队列加分布式状态存储,结果项目做了一半,成本全耗在基础设施联调上,模型效果反而没调过。正确姿势是先搭一个最简循环,让任务能跑通,再按信号逐步补充 Runtime 能力。
3. Harness Runtime 的核心实现拆解
3.1 Runtime 的分层设计
一个可落地的 Harness Runtime 至少要划分四层,每层关注一件事。
最外层是接入层,负责对接消息来源和调用方。无论是用户聊天、定时触发还是 API 请求,统一在这一层转成 Runtime 能理解的任务对象。
第二层是编排层,这是 Runtime 的心脏。它维护一个循环:读取当前状态、组装消息、请求模型、得到决策、执行工具或输出回答、更新状态。所有循环控制和上下文管理都在这层完成。
第三层是工具层,也叫 Tool Registry。它负责管理“Agent 可以调用哪些工具”,包括工具的描述、参数 schema、实际执行函数、超时和重试策略。模型本身不会直接执行任何函数,它只是输出“我要调 order_service 的 query_by_id,参数是 xxx”,真正执行还是由 Runtime 的 tool executor 完成。
最底层是模型适配器和基础设施。模型适配器屏蔽掉不同模型服务商接口的差异,基础设施比如日志、追踪、存储、鉴权,在这里被集中实现。分层设计最大的价值是让每一层都能独立演进。今天你换一个模型服务商,只需要改适配器;明天增加一类工具,不会碰编排逻辑;后天要加强安全审计,在工具层的统一入口加一段钩子就行。
3.2 编排循环:把单次调用变成循环决策
整个 Runtime 里最核心的循环,逻辑其实不复杂。我写过很多遍,核心骨架一直是这么几行:
def run_agent(task_id: str, runtime: Runtime) -> AgentResult: state = runtime.new_session(task_id) for step in range(runtime.max_steps): messages = state.to_messages() response = runtime.llm.chat(messages) if response.has_tool_calls(): # 模型要求调用工具 for call in response.tool_calls: result, error = runtime.tools.execute(call) state.add_tool_result(call.id, result, error) elif response.has_final_answer(): # 模型给出最终回答,循环结束 return AgentResult(state=state, answer=response.text) else: # 模型既没调工具也没给答案,继续跑一轮 state.add_assistant_message(response.text) raise MaxStepsExceeded(state)这段代码里最关键的设计决策,是“模型永远不直接执行函数,它只表达意图”。为什么这么做?因为模型输出天然是概率性的,你没法保证它这次输出的一定是合法 JSON,如果直接拿模型输出的字符串去执行系统调用,一旦格式稍有偏差,轻则报错,重则误操作。把“决策”和“执行”分开,在中间加一层工具执行器,就多了一次校验、重试、记录日志的机会。
另一个决策是“把工具返回结果也当成消息回填给模型”。这里有个微妙的地方:模型看到的是工具返回的文本,不是函数调用栈,也不是异常堆栈。所以工具返回结果本身也要做好序列化,长结果要截断,复杂结构最好转成简明的文本描述,避免模型被一堆无用字段干扰。
3.3 工具注册与执行:Agent 碰触外部世界的桥
工具层是 Agent 最容易翻车的地方。我之前做过一个模拟项目 X,最初只让模型直接调用工具函数,结果模型在参数上疯狂出错,输出了一个根本不存在的门店编号,排查了半天发现问题出在工具描述里没有写清楚参数格式。
后来我把所有工具收敛成统一的注册协议,每个工具必须描述四件事:名称、用途、输入参数 schema、执行函数。我把其中一个查询工具列出来给你看这个结构:
runtime.tools.register( name="query_order_status", description="查订单的最新物流状态,输入平台订单号,返回最近三条物流节点及时间", input_schema={ "type": "object", "properties": { "order_id": { "type": "string", "description": "平台生成的订单号,格式类似 ORD-20250112-0001" } }, "required": ["order_id"] }, executor=query_order_status_executor, timeout_seconds=10, )模型能不能在正确的时机选中这个工具,一半靠描述写得是否清晰,另一半靠参数 schema 是否严格。实际项目里我强烈建议工具描述里包含“什么时候该用这个工具”和“什么时候不该用”的说明,这比单纯堆功能词汇有效得多。比如一个查天气的工具,描述写成“根据城市名查询未来 7 天天气,当用户询问天气、温度、是否下雨时使用”,模型选中的准确率会明显更高。
工具执行层还有两个容易被忽略的点。一是超时控制,外部接口可能很慢,单个工具在 Runtime 里占用循环时间太久,会拖垮整体响应。我的做法是给每个工具设置独立的超时时间,超时后返回一个明确错误给模型,让模型决定是换一种方式还是告诉用户稍后重试。二是并发控制,同一个 Agent 会话里如果多个工具调用同时执行,要注意共享状态要不要加锁。我曾经在功能某跨平台系统改造时遇到过一个问题:两个工具同时尝试更新同一个任务状态,后写入的反而覆盖了先写入的有效数据。最终我选择在会话级别加了一个串行锁,保证工具调用按顺序执行,稳定压倒一切。
3.4 上下文管理:长任务的瓶颈都在内存
这也是 Runtime 和单次调用最不一样的地方。单次调用你用一次 prompt,模型看多少上下文由你决定,但 Agent 每多走一步,消息列表就会变长。模型窗口是有上限的。一旦把早期的重要信息挤出窗口,Agent 就会失忆。
上下文管理常见做法有三种。第一种是纯滑动窗口,只保留最近 N 轮,超出就丢。简单粗暴,适合对话场景,缺点是丢了早期关键约束,用户明明说过“不要短信通知”,滑动窗口把它丢掉后 Agent 可能又建议发短信。第二种是摘要压缩,把早期的对话交给模型重新总结成一段要点,代替原来的完整消息。这种方式相对聪明,但摘要过程本身有信息损失,早期结果里的数字、日期、ID 很容易被吞掉。第三种是混合式,保留最近几轮原始信息,更早的内容转为结构化的摘要,甚至把关键事实单独维护成 KV 记忆区。我最终用的是这种方式。
这里有一个具体的压缩策略实现思路:
def compress_if_needed(state: SessionState, budget: int) -> None: if state.estimate_tokens() <= budget: return while state.estimate_tokens() > budget: oldest_turn = state.pop_oldest_turn() # 单独拎出关键事实,防止被摘要吞掉事实性信息 facts = extract_facts(oldest_turn) state.remember(facts) summary = llm.summarize(oldest_turn) state.add_compressed_summary(summary)执行摘要压缩时,我特别强调把“硬事实”和“对话过程”分开处理。用户提供的订单号、日期、明确指令,都要复制进持久化记忆区,只把模型推理的过程用摘要压缩掉。这样即使窗口再挤,关键信息至少还在。
还有个小技巧值得分享:工具返回结果也要参与上下文预算。很多工具会返回 JSON 里混着一大堆无关字段,模型看的时候浪费 token,还容易被干扰。工具 executor 在回填结果前,应该做一次裁剪,只保留和当前任务最相关的字段。这一步对长任务的稳定性提升非常明显。
3.5 安全护栏与退出条件:失控前的最后一道闸
没有护栏的 Agent 等于事故机器。我见过太多项目,模型在循环里越跑越偏,最后要么死循环烧钱,要么调用了不该调用的接口。护栏至少要覆盖五个方面:
第一,工具白名单。Agent 只能调用注册过的合法工具,而且每个用户、每个会话可能还有不同的权限集。不要让一个只查公开信息的 Agent 拥有删除数据库的工具,这种低级错误一旦发生,补救成本很高。
第二,参数校验。模型给出的参数可能不符合 schema,也可能包含恶意注入内容。在工具执行前,Runtime 必须按 schema 校验一次,不通过就直接给模型反馈错误,让模型重新生成参数,而不是拿着脏参数去访问真实系统。
第三,步数限制。每个任务最多走多少步必须写死。我通常给大多数任务设置 10 步,超过就强制终止并通知用户。死循环的代价不只是算力浪费,还会拖垮整个 Runtime 的可用性。
第四,人工介入 hook。涉及高影响操作时,Runtime 应该暂停循环,发起一个人工审批请求,等审批通过后才继续执行。比如发送对外消息、删除数据、大批量修改记录等场景,都应该设置这个闸门。
第五,退出语义。日志里要清楚区分“正常完成、达到步数上限、工具错误导致提前终止、人工取消”这几种结果。否则排查问题时你根本不知道 Agent 是干完了还是被掐死的。
每轮循环做什么决策、为什么这样决策、看到什么观察结果,全部以结构化方式落日志。这是 Runtime 和裸调用在可维护性方面最大的区别。
4. 实操过程参考:从零搭一个最小 Runtime 的落地清单
4.1 最小闭环的落地清单
如果你刚准备开始搭 Runtime,照着下面这份清单推进,顺序很重要,不要跳。
第一步,先定义任务对象。想清楚用户需求怎么进来,任务包含哪些字段,比如任务 ID、初始指令、创建时间、状态。
第二步,搭消息结构。把历史消息、工具调用记录、工具返回结果统一成消息列表,发给模型的时候序列化成模型偏好的格式。
第三步,实现最简循环。先别急着加摘要、记忆、并发控制,只要能在 for 循环里跑出“模型决定调工具、执行、回填、再决策”这个闭环,就说明主链路通了。
第四步,注册第一批工具。建议只加两三个核心工具,把注册、执行、错误反馈三个环节跑顺,再扩充工具数量。
第五步,加入上下文预算和压缩逻辑。这一步很关键,没有它在 Demo 阶段可能没感觉,但只要任务变长一定炸。
第六步,加安全护栏和控制项。至少把步数限制和工具白名单加上,这两样不花太多代码但能避免大部分事故。
第七步,做日志和追踪。给每个任务生成一个 trace ID,把每次循环的输入输出、工具调用、耗时都记录到日志系统。
第八步,接人工审批和告警。等任务链路稳定以后,把需要高权限的工具改成审批模式,再把失败率告警接入监控体系。
4.2 关键参数怎么选
配置 Runtime 时,下面几个参数我建议重点关注。
模型方面,工具调用类任务优先选择带结构化输出的模型,温度建议设在 0 到 0.3 之间。温度过高会让模型在“该调工具还是该直接回答”之间摇摆,实测下来不仅速度变慢,稳定性也会变差。
循环步数方面,简单任务 5 步以内,复杂任务也不建议超过 20 步。步数给得太多,既浪费时间,又容易让模型在错误路径上越走越深。我还习惯在让步数限制前先问自己:如果人类做这件事,正常要几步?按这个思路来设,通常不会离谱。
超时设置方面,工具执行超时建议 5 到 15 秒,整体任务超时再乘一个系数。有些外部接口本来就要 30 秒才能返回,Runtime 侧的 5 秒超时会导致频繁误判,这种工具应该改成异步模式,不要让模型干等。
并发控制方面,如果你要同时跑多个 Agent 会话,一定要在 Runtime 层做并发配额管理,限制同时在跑的会话数。模型接口的 QPS 和工具系统的负载都要纳入考虑,否则高峰期一压进来,所有 Agent 都开始超时重试,雪崩式故障。
4.3 从 MVP 到生产级:三个演进阶段
我给很多团队推过这套演进路线,效果比较稳定。
第一阶段是全内存 MVP。所有状态都存在内存里,工具只有两三个,没有鉴权,没有日志系统。这个阶段的唯一目标是跑通链路,验证模型在这个任务上到底能不能完成预期闭环。
第二阶段是生产级的单机 Runtime。状态持久化到数据库,消息缓存和摘要存储独立出来,工具注册和权限校验完备,日志和追踪接通。这个阶段可以支撑真实业务,但要关注单机性能上限。
第三阶段是高可用分布式 Runtime。把会话管理、工具执行器、模型适配器拆成独立服务,通过消息队列解耦,支持水平扩容。这个阶段适合工具数量多、并发高、需要多团队协作的场景。我的建议是:不是业务量到了这一步,不要引入第三阶段的复杂度,分布式是手段不是目的。
5. 常见问题与排查实录
5.1 问题与排查速查表
下面这张表是我在实际项目里反复用到的问题排查清单,几乎覆盖了 Agent Runtime 最常见的故障场景。
| 症状 | 可能原因 | 排查与解决方式 |
|---|---|---|
| Agent 陷入死循环,反复调用同一个工具 | 工具返回结果对模型没有新信息量,或退出条件缺失 | 检查工具结果是否被裁剪成无意义文本;设置最大步数;在工具描述中明确“查不到时直接告知用户” |
| 工具参数频繁报错 | schema 描述不严格,或模型没理解参数格式 | 用严格 JSON Schema 校验;在描述里给示例值;工具执行前统一做参数清洗 |
| 任务跑得越来越慢,token 消耗暴涨 | 上下文无限累积,没有压缩策略 | 加滑动窗口或摘要压缩;工具返回结果回填前裁剪无用字段 |
| 模型在该调工具时直接给出编造答案 | 温度过高、工具描述含糊或模型不支持工具调用 | 降温;重写工具描述;更换支持结构化输出的模型 |
| 会话状态错乱,多个任务互相污染 | 状态管理缺隔离,共享了全局变量 | 用会话 ID 隔离状态;禁止在工具函数里读写全局内存 |
| 工具执行成功但 Agent 说“操作失败” | 工具返回文本不清晰,模型误解了结果 | 优化工具返回文案,明确“成功”“失败”“数据为空”三种语义,不要只丢原始 JSON |
| 高并发下任务集体超时 | 外部接口被耗尽或模型接口限流 | 做并发配额、限流降级;高风险工具改异步,不要占着循环等待 |
5.2 两个让我印象深刻的踩坑记录
第一个踩坑是在为某跨平台系统做数据同步 Agent 时遇到的。当时为了让模型能直接操作内部接口,我在工具执行器里只做了“能不能调”的粗粒度判断,没有对参数做 schema 级校验。结果模型在一次任务中生成了一个包含异常值的查询条件,把内部服务端打出了大量报错日志。后来我把每个工具的参数校验下沉到工具注册协议层,任何参数进入执行函数前都要过一遍 schema,就是 3.3 里那个工具注册结构。上线后的两个星期里,这类因为参数错误导致的问题率直接下降了接近八成。
第二个踩坑来自我做模拟项目 X 的摘要压缩模块。最初为了省成本,我用了一个参数较小、能力较弱的模型来做历史对话摘要。结果发现它会把一些关键数字给吞掉,比如把“退款金额 1200 元”压缩成“用户要求退款”,后续 Agent 在处理这笔退款时根本不知道该退多少,又折回去问用户,用户自然很困惑。后来改成混合策略:硬事实单独存记忆,只用强模型压缩“对话过程”,并且对压缩后的摘要做一次“是否包含所有关键实体”的抽查。从那以后,长任务的失忆率才压到可接受范围。
5.3 让 Runtime 稳定运行的几条实战建议
说到底,Runtime 的终极任务是稳定、可控、可复盘。我总结了四条建议,希望你少走弯路。
第一,模型输出格式的解析要做容错,不要假设模型每次都会输出完美 JSON。一定要有独立的解析恢复层,解析失败就把原文回灌给模型让它纠错。
第二,所有外部系统交互都要有一层隔离。不要让工具函数直接暴露内部服务的复杂对象给模型,先转成模型容易理解的文本摘要,再参与后续推理。
第三,Agent 上生产前,必须给“关键操作”设计人工审批。某些操作一旦由模型全自动执行,出事后排查成本会高到离谱。
第四,每一个任务都要有独立的 trace ID。前端用户投诉“它做错了”,你能拿着 trace ID 把每一步决策清楚地翻出来,才算真正可运维。
我在实际项目中最大的体会是:把“循环推进”和“工具执行”之间那条边界做干净,比任何花哨的 prompt 技巧都管用。再分享一个小技巧:给每个步骤附加一个小的“意图标记”,比如这是属于“搜索事实、执行操作、还是确认条件”,日志和复盘会异常轻松,排查问题时一眼就能看清模型当时的想法。这个内容你若是在做 Agent 相关项目,建议直接保存下来,后面大概率用得上。