Agent Harness工程实战:从能跑到稳定跑的架构设计与避坑指南
1. 为什么“能跑”和“稳定跑”之间隔着一整个 Harness 工程我最早接触 Agent 开发的时候和大多数人一样觉得这东西的核心就是提示词。把 System Prompt 写得足够细把工具描述写得足够清楚模型就能乖乖干活。结果第一次把它放到真实业务里跑当天就翻车了模型在第三步突然忘记了自己要干什么开始胡言乱语工具调用返回了一个超时错误整个链路直接崩掉没有任何重试上下文越堆越长到后面模型开始重复之前已经执行过的操作。那一刻我才意识到Agent 的瓶颈从来不在模型本身而在于包裹在模型外面的那一层工程结构。这层结构业内现在越来越多地用一个词来指代——Harness。你可以把它理解成“马具”或者“线束”模型是那匹有力气但方向感不稳定的马Harness 就是套在它身上、约束它、引导它、保护它、让它能稳定拉车的那一整套装置。没有 Harness 的 Agent就是一个能说会道但随时可能失控的聊天机器人有了 Harness它才变成一个可以交付、可以运维、可以扛住真实流量的系统。这篇文章我想聊的就是这套 Harness 工程到底包含什么、每个部分为什么这么设计、实际落地时哪些坑必须提前踩过。适合已经写过一两个 Demo、准备把 Agent 推向生产环境的开发者也适合正在做 Agent 架构选型的技术负责人。我不会只讲概念会把参数怎么定、重试怎么做、上下文怎么裁剪、状态怎么持久化这些实操细节都摊开讲。Harness 和 Agent 的区别本质上就是“能不能稳定交付”的区别这也是我踩了无数坑之后最深的体会。2. Harness 工程的整体设计与核心思路拆解2.1 先搞清楚 Harness 到底管什么很多人第一次听到 Harness 会懵觉得这不就是“Agent 框架”换个说法吗。其实不是。框架比如 LangChain、LangGraph、Spring AI解决的是“怎么把模型、工具、记忆拼起来”的问题而 Harness 解决的是“拼起来之后怎么让它稳定运行”的问题。框架是骨架Harness 是神经系统加免疫系统。我习惯把 Harness 拆成五个职责层每一层都对应一类真实故障职责层解决的问题缺失后的典型故障循环控制什么时候继续、什么时候停无限循环、提前终止工具调度调用哪个工具、参数怎么校验参数错乱、调用不存在的工具上下文管理什么进上下文、什么被裁剪上下文溢出、关键信息丢失错误恢复出错后怎么办一次失败全链路崩溃状态与可观测当前在哪、出过什么事无法调试、无法断点续跑这五层不是并列的而是有依赖关系的。循环控制是主干工具调度和上下文管理挂在主干上错误恢复是横切关注点状态与可观测是底座。设计 Harness 的时候我建议就按这个顺序来搭先把循环跑通再逐步加厚其他层。2.2 为什么不能把控制权全交给模型新手最容易犯的错是把“下一步做什么”完全交给模型决定。模型说调用工具就调用模型说结束就结束。这在 Demo 里没问题在生产里是灾难。原因很简单模型的输出是概率性的而生产系统需要确定性。我的做法是引入一个显式的状态机。Agent 的每一次迭代状态只能在这几个之间流转IDLE → PLANNING → TOOL_CALLING → OBSERVING → REFLECTING → DONE/FAILED。模型只能在PLANNING和REFLECTING阶段输出内容而状态之间的跳转由 Harness 的代码逻辑决定不由模型说了算。这样即使模型抽风最坏情况也只是某个阶段输出质量差而不会让整个流程失控。这个设计还有一个好处每个状态都可以单独打日志、单独设超时、单独做重试。调试的时候你能精确知道卡在哪一步而不是面对一坨“模型又乱说话了”的黑盒。2.3 工具调度为什么需要一层“中间人”直接让模型输出工具调用然后代码去执行这是最直觉的做法。但真实场景里模型给出的参数经常有问题字段名拼错、类型不对、必填项缺失、甚至调用一个根本没注册的工具。如果每次都靠 try-catch 兜底代码会变得极其丑陋。所以我在模型和真实工具之间加了一层调度器。调度器的职责包括校验工具名是否在白名单内、校验参数是否符合 schema、对参数做必要的类型转换和默认值填充、执行前做权限检查、执行后统一包装返回格式。这一层看起来是“多此一举”但它把大量脏活从业务代码里剥离出来了。提示工具 schema 一定要用严格的 JSON Schema 定义不要图省事用自然语言描述参数。模型对结构化 schema 的遵循度远高于自然语言描述这是实测下来最明显的差异之一。2.4 上下文管理是 Harness 里最容易被低估的部分我见过太多 Agent 项目死在上下文上。要么是上下文无限增长导致成本和延迟飙升要么是裁剪策略太粗暴把关键信息删了导致模型失忆。上下文管理的核心矛盾是你需要保留足够的历史让模型理解当前处境但又不能让它无限膨胀。我的策略是分层管理。把上下文分成三类系统层System Prompt、工具定义永远保留、任务层当前目标、已完成步骤的摘要动态更新、对话层原始的工具调用和返回可裁剪。裁剪的时候优先压缩对话层把多轮工具调用合并成一句摘要而不是直接删掉。任务层用结构化的方式维护比如一个 JSON 记录“已完成哪些步骤、当前卡在哪、下一步计划”这样即使对话层被裁得很狠模型也不会失去方向感。3. 核心机制细节解析与实操要点3.1 循环控制终止条件必须多重保险Agent 的循环控制说白了就是回答“什么时候停”。最危险的情况是无限循环模型一直觉得任务没完成一直调用工具烧钱又烧时间。我一般会设三重终止条件任何一重触发都强制停止。第一重是最大迭代次数。这个值怎么定我的经验是看任务的复杂度。简单的单工具任务5 到 8 次足够多步骤的复杂任务15 到 20 次再往上就要警惕是不是任务定义本身有问题了。我通常默认设 15超过这个数还没结束大概率是模型陷入了某种循环。第二重是无进展检测。记录最近几轮的工具调用和返回如果连续三轮的调用参数高度相似、返回结果也高度相似说明模型在原地打转直接终止。这个检测用简单的字符串相似度或者哈希比对就能实现不需要多复杂。第三重是显式完成信号。要求模型在任务完成时输出一个特定的结构化标记比如{status: done, result: ...}。Harness 解析到这个标记才认为任务真正结束而不是模型随便说一句“我完成了”就信。MAX_ITERATIONS 15 NO_PROGRESS_THRESHOLD 3 def should_terminate(state): if state.iteration MAX_ITERATIONS: return True, max_iterations_reached if state.no_progress_count NO_PROGRESS_THRESHOLD: return True, no_progress_detected if state.explicit_done: return True, task_completed return False, None注意无进展检测的阈值不要设得太小有些任务确实需要多次相似调用才能收敛比如轮询某个状态。3 次是我实测下来比较平衡的值既不会误杀正常任务也能及时止损。3.2 工具调用的参数校验与容错工具调用出错是家常便饭关键是怎么优雅地处理。我的调度器里有一套固定的校验流程按顺序执行工具名白名单检查、参数 schema 校验、必填项检查、类型转换、默认值填充、权限检查。任何一步失败都不直接抛异常而是返回一个结构化的错误信息给模型让模型有机会自我修正。这里有个细节很关键错误信息要写得让模型能看懂并据此修正。比如参数类型错误不要只返回“类型错误”而要返回“参数 timeout 期望是整数实际收到字符串 30s请提供纯数字”。模型看到这种明确的反馈下一轮大概率能改对。我实测下来这种“带引导的错误返回”能把工具调用的首次成功率提升一大截。另一个技巧是给工具调用设超时。有些工具比如网络请求、数据库查询可能卡住如果不设超时整个 Agent 就挂在那里了。我一般给单个工具调用设 30 秒超时超时后返回一个明确的超时错误让模型决定是重试还是换方案。3.3 上下文裁剪的具体策略与参数上下文裁剪不是简单地把老消息删掉那样会让模型失去连贯性。我的做法是“摘要 保留关键节点”。具体来说当上下文长度超过阈值我一般设模型上下文窗口的 70%时触发裁剪。裁剪时保留最近 N 轮的完整对话N 一般取 5更早的部分压缩成摘要。摘要怎么生成可以用一个便宜的小模型来做把多轮工具调用压缩成“调用了 X 工具得到 Y 结果用于 Z 目的”这样的结构化描述。这样既省 token又保留了关键信息。摘要的粒度要控制好太粗会丢信息太细等于没压缩。我的经验是每个已完成步骤压缩成一句话整个摘要控制在 500 token 以内。还有一个容易被忽略的点工具定义本身也占上下文。如果你注册了几十个工具光工具描述就可能吃掉几千 token。我的做法是按任务动态加载工具只把当前任务可能用到的工具放进上下文而不是一股脑全塞进去。这个优化能省下大量 token也能减少模型选错工具的概率。3.4 错误恢复重试、降级与人工兜底错误恢复是 Harness 里最能体现工程功力的部分。我把错误分成三类分别用不同策略处理。第一类是瞬时错误比如网络抖动、限流、临时超时。这类错误直接重试用指数退避重试 3 次。退避的基数我一般设 1 秒即 1s、2s、4s避免短时间内疯狂重试把下游打挂。第二类是可修正错误比如参数错误、工具返回业务异常。这类错误不重试而是把错误信息返回给模型让模型调整后重新调用。这其实就是前面说的“带引导的错误返回”。第三类是致命错误比如工具不存在、权限不足、依赖服务彻底不可用。这类错误直接终止当前任务记录详细日志并触发告警。如果任务支持断点续跑就把当前状态持久化等人工介入后再恢复。def handle_error(error, state): if error.type transient: return retry_with_backoff(state, max_retries3, base_delay1) elif error.type correctable: return feed_back_to_model(error.message, state) else: persist_state(state) alert(error) return terminate(state)提示重试一定要设上限并且要区分“重试整个任务”和“重试单个工具调用”。前者代价大后者代价小。能用后者解决的绝不用前者。4. 实操过程与核心环节实现4.1 从零搭一个最小可用的 Harness光讲理论没意思我把搭一个最小 Harness 的过程完整走一遍。假设我们用 Python模型走标准的对话接口工具就是几个普通的函数。第一步定义状态结构。这是整个 Harness 的核心数据结构所有信息都挂在上面。from dataclasses import dataclass, field from typing import List, Dict, Any dataclass class AgentState: task: str iteration: int 0 status: str IDLE messages: List[Dict] field(default_factorylist) completed_steps: List[str] field(default_factorylist) current_plan: str no_progress_count: int 0 last_tool_calls: List[str] field(default_factorylist) result: Any None第二步写主循环。主循环的逻辑就是不断推进状态直到触发终止条件。def run_agent(task: str, tools: dict, max_iter: int 15): state AgentState(tasktask) state.messages.append({role: system, content: build_system_prompt(tools)}) state.messages.append({role: user, content: task}) while True: terminate, reason should_terminate(state) if terminate: state.status DONE if reason task_completed else FAILED break state.iteration 1 response call_model(state.messages) if response.has_tool_call: state.status TOOL_CALLING tool_result dispatch_tool(response.tool_call, tools) state.messages.append({role: assistant, content: response.raw}) state.messages.append({role: tool, content: tool_result}) update_progress(state, response.tool_call, tool_result) else: state.status REFLECTING state.messages.append({role: assistant, content: response.raw}) if is_done_signal(response.raw): state.explicit_done True state.result extract_result(response.raw) state.messages maybe_trim_context(state.messages) return state第三步实现工具调度器。这是把模型输出和真实函数连接起来的关键。def dispatch_tool(tool_call, tools): name tool_call.get(name) args tool_call.get(arguments, {}) if name not in tools: return json.dumps({error: f工具 {name} 不存在可用工具{list(tools.keys())}}) schema tools[name][schema] valid, msg validate_args(args, schema) if not valid: return json.dumps({error: f参数校验失败{msg}}) try: result tools[name][func](**args) return json.dumps({result: result}, ensure_asciiFalse) except Exception as e: return json.dumps({error: f工具执行异常{str(e)}})这三步搭完一个最小 Harness 就能跑了。但能跑不等于稳定接下来要往里加东西。4.2 上下文裁剪的实现细节裁剪逻辑我单独抽成一个函数因为它涉及不少判断。def maybe_trim_context(messages, max_tokens6000, keep_recent5): if count_tokens(messages) max_tokens: return messages system_msgs [m for m in messages if m[role] system] other_msgs [m for m in messages if m[role] ! system] recent other_msgs[-keep_recent:] older other_msgs[:-keep_recent] if older: summary summarize(older) summary_msg {role: system, content: f历史步骤摘要{summary}} return system_msgs [summary_msg] recent return system_msgs recent这里count_tokens可以用 tiktoken 之类的库也可以用简单的字符数估算。summarize我一般调一个小模型来做把多轮对话压缩成结构化摘要。注意摘要要保留“做了什么、得到什么、为什么这么做”这三个要素缺一不可。4.3 无进展检测的具体实现无进展检测的核心是判断“最近几轮是不是在做重复的事”。我用工具名加参数哈希来做比对。import hashlib def update_progress(state, tool_call, tool_result): signature hashlib.md5( f{tool_call[name]}:{json.dumps(tool_call[arguments], sort_keysTrue)}.encode() ).hexdigest() state.last_tool_calls.append(signature) if len(state.last_tool_calls) 3: state.last_tool_calls.pop(0) if len(state.last_tool_calls) 3 and len(set(state.last_tool_calls)) 1: state.no_progress_count 1 else: state.no_progress_count 0这个逻辑的意思是如果连续三次调用的工具和参数完全一样就认为没有进展。实际用的时候可以放宽一点比如参数相似度超过 90% 也算重复避免模型微调参数后反复试探。4.4 状态持久化与断点续跑生产环境的 Agent 必须支持断点续跑否则一旦进程挂掉之前的工作全白费。我的做法是每完成一个步骤就把AgentState序列化存到数据库或 Redis 里。def persist_state(state, store): store.set(fagent:{state.task_id}, json.dumps(asdict(state))) def load_state(task_id, store): raw store.get(fagent:{task_id}) if raw: return AgentState(**json.loads(raw)) return None恢复的时候从存储里读出状态重新进入主循环即可。这里有个细节恢复后要重新构建上下文因为消息列表可能已经被裁剪过。我的做法是把completed_steps和current_plan重新注入到 System Prompt 里让模型快速恢复上下文感知。注意状态持久化的频率要权衡。每步都存会增加延迟存得太少又可能丢进度。我的经验是每个工具调用完成后存一次纯模型推理的中间状态可以不存。5. 常见问题与排查技巧实录5.1 模型不调用工具只输出文字怎么办这是最常见的问题之一。模型明明有工具可用却选择用自然语言回答。原因通常有三个工具描述不够清晰、System Prompt 没有强调工具优先、或者模型本身能力不足。排查顺序是这样的先看工具描述是不是写得太抽象了。工具描述要具体到“什么时候用、输入什么、输出什么”最好带一两个例子。然后看 System Prompt有没有明确说“当需要外部信息或执行操作时必须调用工具不要凭记忆回答”。如果这两点都没问题那就是模型能力问题换一个工具调用能力更强的模型。我实测下来工具描述里加上“使用场景”这一项能显著提升调用率。比如不要只写“查询天气”而要写“当用户询问某地天气、温度、是否下雨时使用此工具”。5.2 上下文溢出导致模型报错上下文溢出通常发生在长任务里。表现是模型接口直接返回错误说 token 超限。这时候要检查两件事裁剪逻辑有没有生效、工具定义是不是太多。裁剪逻辑不生效的常见原因是阈值设得太大或者count_tokens算得不准。我建议阈值设在模型窗口的 70%留 30% 的余量给模型输出。工具定义太多的话就做动态加载按任务阶段只加载相关工具。还有一个隐蔽的坑有些工具返回的结果特别长比如返回一大段 HTML如果不做截断一次调用就能把上下文撑爆。我的做法是在工具调度器里对返回结果做长度限制超过阈值的部分截断并加省略标记。5.3 工具调用参数总是出错参数出错的原因八成是 schema 定义不够严格。我见过有人用自然语言描述参数比如“timeout 参数是超时时间”结果模型一会儿传数字一会儿传字符串。正确做法是用标准 JSON Schema明确 type、required、enum 等约束。如果 schema 已经很严格了还是出错那就是模型对 schema 的理解有问题。这时候可以在 System Prompt 里加一段“参数填写规范”把容易出错的参数单独拎出来强调。另外调度器里的类型转换和默认值填充也能兜住一部分错误不要指望模型每次都完美。5.4 常见问题速查表问题现象可能原因排查方向解决手段无限循环终止条件缺失检查迭代计数和无进展检测加多重终止条件上下文溢出裁剪未生效检查阈值和 token 计算降低阈值、动态加载工具工具调用失败schema 不严检查参数定义用严格 JSON Schema任务中途失忆裁剪太粗暴检查摘要质量保留关键节点、结构化摘要恢复后行为异常状态不完整检查持久化字段补全状态、重建上下文响应特别慢工具阻塞检查工具超时加超时、异步化5.5 几个我踩过的坑第一个坑是过度依赖模型的自我反思。我一开始设计了一个“反思阶段”让模型自己检查上一步做得对不对。结果发现模型经常“反思”出一些不存在的问题然后去修一个本来没坏的东西。后来我把反思改成可选的只在特定条件下触发比如工具返回错误时。第二个坑是工具粒度过细。我一开始把每个小操作都做成独立工具结果模型在几十个工具里挑花了眼经常选错。后来我把相关操作合并成粗粒度工具比如把“读文件、写文件、列目录”合并成一个“文件操作”工具用参数区分具体动作。工具数量降下来之后调用准确率明显提升。第三个坑是忽略并发场景。单线程跑得好好的 Agent一上并发就出问题。共享状态被多个任务同时修改导致数据错乱。解决办法是每个任务一个独立的AgentState实例状态存储用任务 ID 做隔离绝不共享可变状态。提示并发场景下工具本身也要考虑线程安全。如果工具内部有共享资源比如数据库连接池要做好隔离或加锁。6. 关于 Harness 工程的一些个人体会写到这里我想聊点不那么技术的东西。做 Agent 开发这两年我最大的感受是这个领域的难点正在从“模型能力”转移到“工程能力”。模型每隔几个月就更新一代能力越来越强但 Harness 这一层的设计思路是相对稳定的。你把循环控制、工具调度、上下文管理、错误恢复这几件事做扎实了换什么模型都能跑得不错反过来Harness 做得烂再强的模型也救不了。我现在的习惯是每接一个新 Agent 需求先不急着写提示词而是先把 Harness 的骨架搭出来把状态机、终止条件、错误处理这些定好然后再往里填业务逻辑。这个顺序看起来慢实际上省了大量后期调试的时间。因为 Harness 稳了之后模型的行为就变得可预测了出问题也能快速定位到是哪一层的问题。还有一个体会是关于“度”的把握。Harness 不是越厚越好加太多约束会让 Agent 变得僵化失去灵活性。我的原则是核心流程用代码强约束边缘决策交给模型。比如“必须调用工具获取数据”这是强约束代码来管“用哪个工具更合适”这是边缘决策模型来定。这个边界划清楚了Agent 既稳定又不失智能。最后分享一个小技巧给 Harness 加一个“回放”功能。把每次任务的完整状态流转记录下来出问题的时候可以回放整个执行过程一步步看模型在哪一步做了什么决策。这个功能在调试复杂任务时简直是救命稻草比看日志高效十倍。实现起来也不难就是把每个状态变更都追加到一个事件流里需要的时候按时间顺序重放即可。