资讯详情 AI Agent Harness 七个子系统:从零搭建可用的 Agent 工程框架
📅 2026/11/3 17:08:56
1. 拆开 AI Agent 的“黑盒子”Harness 到底在干什么很多人第一次听到 AI Agent 里的 Harness 这个词脑子里浮现的是汽车线束或者测试夹具觉得它就是个外围配件。但如果你真正动手从零搭过一个能跑起来的 Agent就会发现 Harness 根本不是配件它是整个系统的骨架和神经中枢。没有 Harness你手里只有一个会聊天的模型有了 Harness模型才能变成能读文件、能调接口、能记住上下文、能自我纠错的“干活机器”。我最早接触这个概念是在做一个自动化工单处理的 Agent 项目。当时团队里有人负责写 Prompt有人负责接 API有人负责做前端展示结果拼在一起跑起来各种诡异问题模型有时候忘了上一步的输出有时候调工具调一半就断了有时候同一个任务反复执行三遍。后来我们把整个中间层抽出来重新设计才发现问题全出在 Harness 这一层没有做扎实。Harness 这个词在英文里本身就有“驾驭、约束、套上马具”的意思放在 AI Agent 语境下它指的就是把大模型的原始能力约束成可控、可观测、可复现的任务执行流程的那套工程框架。你可以把大模型想象成一个极其聪明但完全没有手脚和记忆的顾问。你问他问题他能给你非常漂亮的回答但他不知道昨天你们聊过什么也没法帮你把回答写进数据库更不会主动去查一下最新的库存数据再回答你。Harness 就是给这个顾问配上的秘书团队、工作手册、电话线和档案柜。它负责把用户的模糊需求翻译成模型能理解的指令负责在模型需要外部信息时去调用工具负责把模型的输出解析成结构化数据负责在模型跑偏的时候把它拉回来负责记录每一步发生了什么以便复盘。从系统架构的角度看一个完整的 AI Agent Harness 通常包含七个核心子系统。这七个子系统不是随便凑数的它们各自解决一个特定层面的问题缺一个都会导致 Agent 在某些场景下表现不稳定。下面这张表是我在实际项目中总结出来的七个子系统及其核心职责你可以先有个整体印象后面我会逐个拆开讲。子系统名称核心职责缺失后的典型症状Agent Loop驱动“思考-行动-观察”循环Agent 只回答一次就停不会多步执行LLM Integration统一模型调用接口与参数管理换模型要改遍全项目代码Tool Registry注册、发现、调用外部工具模型不知道有哪些工具可用Memory System短期上下文与长期知识存储多轮对话后模型“失忆”Context Manager上下文窗口的裁剪与压缩长任务跑到一半超出 token 限制Output Parser把模型输出解析成结构化指令工具调用参数解析失败率高Observability日志、追踪、指标、回放出问题只能靠猜无法定位这七个子系统里Agent Loop 和 LLM Integration 是心脏和血管Tool Registry 和 Output Parser 是手脚和神经末梢Memory System 和 Context Manager 是记忆和注意力Observability 是体检报告。接下来我会按照从核心到外围的顺序把这七个子系统逐个拆解讲清楚每个子系统为什么必须存在、怎么设计才合理、以及我在实际搭建过程中踩过的坑。2. 七个核心子系统的深度拆解与设计要点2.1 Agent Loop让模型从“说”变成“做”的驱动引擎Agent Loop 是整个 Harness 里最核心的子系统没有之一。它的本质是一个循环控制器不断重复“让模型思考下一步该做什么 → 执行模型指定的动作 → 把执行结果喂回给模型 → 继续下一轮”这个过程直到模型认为任务完成或者达到终止条件。为什么需要这个循环因为大模型本身是单次推理的你给它一个输入它给你一个输出然后就结束了。但真实任务往往需要多步操作。比如用户说“帮我查一下上个月销售额最高的三个产品然后给每个产品的负责人发一封提醒邮件”。这个任务至少需要查数据库拿到销售数据、排序找出前三、查通讯录找到负责人邮箱、调用邮件接口发送。模型不可能在一次推理里完成所有这些事它需要分步执行每一步根据上一步的结果决定下一步做什么。Agent Loop 的典型实现是一个 while 循环伪代码大概长这样def agent_loop(task, max_steps20): context initialize_context(task) for step in range(max_steps): response llm_integration.call(context) action output_parser.parse(response) if action.type final_answer: return action.content elif action.type tool_call: result tool_registry.execute(action.tool_name, action.params) context context_manager.append(context, action, result) elif action.type ask_user: user_input get_user_input(action.question) context context_manager.append(context, action, user_input) return 达到最大步数限制任务未完成这个循环看起来简单但实际设计时有几个关键决策点。第一个是最大步数限制。我试过不设限制结果有一次模型陷入了一个死循环它反复调用同一个查询工具每次拿到相同结果然后继续调用。后来我加了 max_steps20 作为硬性熔断同时在循环里加了重复动作检测如果连续三次调用同一个工具且参数相同就强制中断并返回错误。第二个决策点是循环终止条件的判断。不能只依赖模型自己说“我完成了”因为模型有时候会自信地宣布任务完成但实际什么都没做。我的做法是在 Output Parser 里定义一套明确的终止信号比如模型必须输出一个特定格式的 JSON里面包含status: completed和summary字段才认为任务真正结束。否则即使模型说“完成了”Loop 也会继续追问“请提供完成摘要”。第三个决策点是错误处理策略。工具调用失败是常态网络超时、参数格式错误、权限不足都会发生。Agent Loop 需要决定是直接把错误信息喂回给模型让它重试还是中断整个任务我的经验是分情况处理。如果是参数格式错误把错误信息喂回去让模型修正参数通常一次就能改对。如果是权限或网络问题重试三次后仍然失败就中断并报告给用户避免无限重试浪费 token。实操心得Agent Loop 里一定要加一个“思考日志”把每一轮的模型输入、输出、工具调用、工具返回都记录下来。这个日志在调试时价值极高因为 Agent 的行为是涌现出来的你不记录根本不知道它为什么走了某条路径。2.2 LLM Integration别让换模型变成噩梦LLM Integration 子系统负责封装所有和大模型交互的细节。很多人觉得这不就是调个 API 吗有什么好封装的但当你项目里同时用了三家不同厂商的模型每家 API 格式不一样、参数名不一样、返回结构不一样、计费方式不一样的时候你就知道这层封装有多重要了。我经历过一次惨痛的教训。早期项目里我直接在业务代码里写死了某家模型的调用方式后来因为成本和效果考虑要换另一家模型结果发现要改的地方遍布十几个文件而且每改一处都要重新测试。从那以后我就学乖了LLM Integration 层必须做到接口统一、配置驱动、能力抽象。接口统一的意思是无论底层用哪家模型上层 Agent Loop 看到的都是同一个call(messages, tools, temperature)方法。返回的也是统一的结构化对象包含content、tool_calls、usage等字段。这样 Agent Loop 完全不需要知道底层是哪个模型。配置驱动是指模型的选择、API Key、超时时间、重试次数这些参数都放在配置文件或环境变量里而不是硬编码。我通常会在配置里定义一个模型列表每个模型有名称、提供商、能力标签比如是否支持 function calling、是否支持视觉输入、成本系数。Agent Loop 根据任务类型和成本预算动态选择模型。能力抽象是更高一层的要求。不同模型对 function calling 的支持程度不一样有的模型原生支持工具调用有的模型需要靠 Prompt 工程来模拟。LLM Integration 层需要把这些差异抹平对上提供统一的工具调用能力。我的做法是在这一层实现一个适配器模式每个模型提供商对应一个适配器适配器负责把统一的工具定义转换成该模型能理解的格式再把模型的返回转换成统一的结构。模型能力原生支持工具调用需要 Prompt 模拟适配策略工具定义格式JSON Schema自然语言描述适配器转换调用返回格式结构化 tool_calls文本中提取正则解析器多工具并行支持通常不支持串行降级流式输出支持部分支持统一流式接口还有一个容易被忽视的点是重试与降级。模型 API 偶尔会超时或返回 5xx 错误LLM Integration 层需要实现指数退避重试。如果主模型连续失败应该自动降级到备用模型。我在配置里通常会设置一个主模型和一个备用模型备用模型可以是更便宜但稍弱的版本保证服务不中断。2.3 Tool Registry给模型装上手脚Tool Registry 子系统管理所有 Agent 可以调用的外部工具。这里的“工具”是一个广义概念可以是查数据库的函数、调第三方 API 的客户端、读文件的工具、执行代码的解释器甚至是另一个 Agent。Tool Registry 的核心职责是注册、发现、描述、执行这四件事。注册是指把工具的定义和实现注册到系统中。我通常用一个装饰器来实现比如tool_registry.register( namequery_sales, description查询指定时间范围内的销售数据, parameters{ start_date: {type: string, description: 开始日期格式 YYYY-MM-DD}, end_date: {type: string, description: 结束日期格式 YYYY-MM-DD}, limit: {type: integer, description: 返回条数上限, default: 100} } ) def query_sales(start_date, end_date, limit100): # 实际查询逻辑 return results这个装饰器做了几件事把工具的名称、描述、参数 schema 存到注册表里同时保留了函数的引用以便后续执行。描述和参数 schema 非常重要因为它们是模型决定是否调用这个工具的唯一依据。描述写得好不好直接决定了模型会不会在正确的时机调用正确的工具。发现是指 Agent Loop 在每一轮需要把当前可用的工具列表传给模型。但并不是所有工具在任何时候都可用有些工具需要特定权限有些工具只在特定任务阶段可用。Tool Registry 需要支持按标签或条件过滤工具列表。比如一个“发送邮件”工具只有在用户明确授权后才会出现在可用工具列表里。描述是 Tool Registry 里最需要花心思的部分。我见过很多项目工具描述写得极其简略比如“查询数据”四个字结果模型根本不知道什么时候该用它。好的工具描述应该包含这个工具做什么、什么时候用、参数含义、返回值格式、有什么限制。我通常会写一段自然语言描述再配上参数级别的说明让模型能准确理解。执行是最后一步但也有很多细节。工具执行需要处理超时、异常、权限校验、结果序列化。我一般会给每个工具设置独立的超时时间比如查询类工具 10 秒写入类工具 30 秒。异常要捕获并转换成模型能理解的错误信息而不是直接抛堆栈。结果序列化要确保返回的内容不超过上下文窗口限制如果结果太大需要做截断或摘要。注意工具的参数校验一定要在服务端做不能只依赖模型输出的参数格式正确。我遇到过模型输出了负数作为 limit 参数结果查询直接报错。后来我在工具执行前加了一层参数校验不符合 schema 的直接返回错误信息让模型重新生成。2.4 Memory System让 Agent 记住该记住的Memory System 解决的是 Agent 的“记忆”问题。大模型本身是无状态的每次调用都是独立的。但真实任务往往需要记住之前发生的事情用户之前说过什么偏好、上一步工具返回了什么结果、之前尝试过哪些方案失败了。Memory System 就是负责管理这些信息的存储和检索。我把 Memory 分成三个层次来设计。第一个层次是工作记忆也就是当前任务的上下文。这部分信息直接放在 Agent Loop 的 context 里每一轮都会传给模型。工作记忆的内容包括原始任务描述、每一步的模型输出、每一步的工具调用和返回、用户的补充输入。工作记忆的特点是容量有限因为要受上下文窗口限制所以需要 Context Manager 来配合管理。第二个层次是会话记忆也就是同一个用户在同一会话中的历史信息。比如用户先问“帮我查一下上个月的销售数据”得到结果后又说“把前三名发邮件给负责人”。第二个请求里的“前三名”和“负责人”都依赖于第一个请求的结果。会话记忆需要跨多个 Agent Loop 实例保持。我的做法是用一个会话 ID 来关联每次 Agent Loop 启动时根据会话 ID 加载历史摘要。第三个层次是长期记忆也就是跨会话的知识。比如用户的偏好设置、常用联系人、历史任务模式。长期记忆通常存在数据库或向量库里需要时通过检索来获取。我一般会用向量数据库存一些非结构化的经验总结比如“这个用户喜欢用表格格式看数据”“这个用户通常在下班后提交任务”。当新任务开始时根据任务描述检索相关的长期记忆注入到工作记忆里。记忆层次存储位置生命周期典型内容检索方式工作记忆内存/Context单次任务当前步骤输入输出直接拼接会话记忆缓存/数据库单次会话历史请求与结果按会话ID加载长期记忆向量库/数据库永久用户偏好、经验语义检索Memory System 设计里最容易踩的坑是记忆污染。如果工作记忆里塞了太多无关信息模型会被干扰做出错误判断。我遇到过一种情况Agent 在查销售数据时工作记忆里还残留着上一个任务的邮件模板内容结果模型把邮件模板的占位符当成了真实数据。后来我在每次任务切换时都会清空工作记忆只保留必要的会话摘要。另一个坑是记忆检索的准确率。长期记忆用向量检索时如果 embedding 模型选得不好检索出来的内容可能完全不相关。我的经验是长期记忆的写入要克制只存那些真正有复用价值的信息比如用户明确表达的偏好、反复出现的任务模式。不要什么都往长期记忆里塞否则检索噪声会越来越大。2.5 Context Manager在有限窗口里塞进最多有效信息Context Manager 和 Memory System 是紧密配合的但职责不同。Memory System 负责“存什么”和“取什么”Context Manager 负责“怎么放”和“放多少”。大模型的上下文窗口是有限的虽然现在动辄 128K、200K token但实际使用中你会发现 token 消耗速度远超预期尤其是当工具返回结果很长的时候。Context Manager 的核心任务是在每一轮调用模型之前把工作记忆、会话摘要、长期记忆检索结果、工具定义、系统提示词这些内容组装成一个不超过窗口限制的 messages 列表。组装策略直接影响到模型的表现和成本。我常用的组装策略是分层裁剪加摘要压缩。系统提示词和工具定义是固定开销通常放在最前面这部分不能裁剪。最近几轮的对话和工具返回放在最后面因为模型对最近的内容注意力最强。中间的历史内容如果太长就用一个摘要模型压缩成简短描述。具体来说我会设置一个 token 预算比如总窗口 32K系统提示词和工具定义占 4K最近三轮占 8K那么中间历史最多占 20K超出的部分触发摘要压缩。摘要压缩的做法是把最早的那部分对话历史拿出来让一个便宜的小模型生成一段摘要然后用摘要替换原始内容。摘要的 prompt 大概是“请用不超过 200 字总结以下对话的关键信息包括用户意图、已执行的操作、得到的关键结果、未解决的问题”。这样一轮压缩下来可能把 5000 token 的历史压到 300 token。还有一个技巧是工具返回结果的截断与结构化。工具返回的原始数据往往很长比如一个查询返回了 100 条记录每条记录有 20 个字段。直接塞进上下文会瞬间占满窗口。我的做法是在 Tool Registry 层就对返回结果做处理如果结果超过一定长度只保留关键字段和前 N 条其余部分用“共 X 条记录已省略”代替。同时把结构化数据转成紧凑的格式比如用 CSV 代替 JSON能省不少 token。实操心得Context Manager 里一定要加一个 token 计数和预警机制。我通常会在每轮组装完成后计算一下总 token 数如果超过窗口的 80%就在日志里打警告。这样可以在真正超限之前就发现上下文膨胀的趋势及时调整策略。2.6 Output Parser把模型的“自由发挥”变成“可执行指令”Output Parser 子系统负责把模型的自然语言输出解析成结构化的动作指令。这是 Harness 里最容易被低估但实际最影响稳定性的部分。模型输出的格式可能千奇百怪有时候是纯 JSON有时候是 JSON 外面包了一层 markdown 代码块有时候是自然语言里夹着 JSON有时候干脆格式完全错误。Output Parser 的设计目标是高容错、可恢复、有反馈。高容错是指能处理各种格式变体。我的做法是先用正则提取可能的 JSON 块然后尝试解析。如果解析失败再尝试用更宽松的解析器比如允许尾随逗号、允许单引号。如果还是失败就把原始输出和解析错误信息一起返回给模型让它重新生成。可恢复是指解析失败后不是直接崩溃而是给模型一次修正机会。我通常会在 Output Parser 里实现一个重试逻辑第一次解析失败把错误信息追加到上下文里让模型重新输出第二次还失败就降级到人工介入或返回默认动作。有反馈是指解析结果要包含足够的信息供后续环节使用。一个解析成功的动作对象应该包含动作类型工具调用/最终回答/请求用户输入、工具名称、参数、原始文本、解析置信度。置信度这个字段很有用当置信度低的时候Agent Loop 可以决定是否要二次确认。def parse_model_output(raw_text): # 尝试提取 JSON json_str extract_json_block(raw_text) if json_str: try: data json.loads(json_str) return Action( typedata.get(action), tool_namedata.get(tool), paramsdata.get(params, {}), confidence0.95 ) except json.JSONDecodeError: pass # 尝试从自然语言中提取工具调用意图 tool_match re.search(r调用(\w)工具, raw_text) if tool_match: return Action( typetool_call, tool_nametool_match.group(1), params{}, confidence0.6 ) # 无法解析 return Action(typeparse_error, raw_textraw_text, confidence0.0)Output Parser 里还有一个重要决策是是否强制模型输出 JSON。有些团队会用 function calling 的原生能力让模型直接输出结构化的 tool_calls这样解析成功率最高。但并不是所有模型都支持而且有些场景下模型用自然语言表达更灵活。我的折中方案是对于工具调用尽量用原生 function calling对于最终回答允许自然语言但要求包含特定的标记字段。2.7 Observability出问题时别靠猜Observability 子系统是 Harness 的“黑匣子”记录 Agent 运行过程中的所有关键事件。很多人搭建 Agent 时会把精力全放在功能实现上等到线上出问题才发现没有任何日志可查只能靠复现和猜测。我从第二个项目开始就把 Observability 作为一等公民来设计。Observability 需要记录的内容包括每一轮模型调用的输入输出和 token 消耗、每一次工具调用的参数和返回、每一步的耗时、整个任务的完整轨迹、错误和异常。这些数据要能以任务 ID 为维度串联起来方便回放整个执行过程。我通常会用结构化日志的方式记录每条日志是一个 JSON 对象包含时间戳、任务 ID、步骤序号、事件类型、事件内容。然后把这些日志送到一个集中的日志系统里方便查询和可视化。对于关键指标比如任务成功率、平均步数、平均 token 消耗、工具调用失败率我会用计数器或直方图来统计定期看趋势。观测维度记录内容用途模型调用输入 messages、输出内容、token 数、耗时分析模型行为、优化 Prompt工具调用工具名、参数、返回、耗时、成功/失败排查工具问题、优化工具描述循环控制步数、终止原因、重复动作检测发现死循环、优化终止条件错误异常错误类型、堆栈、上下文定位 bug、改进容错成本每步 token 消耗、累计成本成本控制、模型选择Observability 还有一个高级用法是轨迹回放。把一次任务的所有日志按时间顺序重放可以像看录像一样看到 Agent 每一步在做什么、为什么这么做。我在调试复杂任务时经常用这个功能比看静态日志高效得多。实现方式是把日志按步骤序号排序然后逐条展示关键步骤可以展开看详细的输入输出。注意日志里可能包含敏感数据比如用户查询的销售数据、邮件内容。在生产环境里一定要做脱敏处理或者设置日志保留期限。我一般会在日志写入前对特定字段做掩码同时把详细日志的保留时间设为 7 天统计指标保留 90 天。3. 从零搭建一个最小可用 Harness 的实操过程3.1 环境准备与项目结构在开始写代码之前先把项目结构定好。我习惯按子系统来组织目录这样每个部分的职责边界很清晰后续替换或升级某个子系统时不会牵一发而动全身。下面是我常用的目录结构agent-harness/ ├── config/ │ ├── models.yaml # 模型配置 │ └── tools.yaml # 工具配置 ├── harness/ │ ├── loop.py # Agent Loop │ ├── llm_integration.py # LLM 集成层 │ ├── tool_registry.py # 工具注册表 │ ├── memory.py # 记忆系统 │ ├── context_manager.py # 上下文管理 │ ├── output_parser.py # 输出解析器 │ └── observability.py # 观测层 ├── tools/ │ ├── database.py # 数据库查询工具 │ ├── email.py # 邮件发送工具 │ └── file.py # 文件操作工具 ├── main.py # 入口 └── requirements.txt依赖方面核心需要的是一个 HTTP 客户端来调模型 API一个 JSON 处理库一个日志库。如果要用向量检索做长期记忆还需要一个向量数据库客户端。我一般用httpx做异步 HTTP 调用pydantic做数据校验structlog做结构化日志。这些库都比较成熟安装配置简单。配置文件的写法我倾向于用 YAML因为可读性好支持注释。模型配置里定义每个模型的名称、API 地址、API Key 的环境变量名、超时时间、最大重试次数、能力标签。工具配置里定义每个工具的启用状态、超时时间、权限要求。3.2 核心循环的编码实现Agent Loop 的编码实现是整个搭建过程中最需要仔细设计的部分。我通常会把它写成一个类包含初始化、单步执行、循环控制、终止判断这几个方法。初始化时接收任务描述、会话 ID、配置对象然后加载记忆、组装初始上下文。单步执行方法负责调用模型、解析输出、执行动作、更新上下文。这里的关键是把每个环节的错误都捕获住转换成模型能理解的反馈。比如模型调用超时了不是直接抛异常而是返回一个“模型调用超时请稍后重试”的动作结果让循环决定是否重试。循环控制方法维护步数计数、重复动作检测、超时控制。我一般会设置三个维度的限制最大步数、最大总耗时、最大连续重复动作次数。任何一个触发都终止循环并返回当前状态。终止判断方法检查模型输出是否包含完成信号。我定义的完成信号是一个特定的 JSON 结构包含status字段和summary字段。只有这两个字段都合法才认为任务真正完成。否则即使模型说“我完成了”循环也会继续。class AgentLoop: def __init__(self, task, session_id, config): self.task task self.session_id session_id self.config config self.memory MemorySystem(session_id) self.context_manager ContextManager(config) self.llm LLMIntegration(config.models) self.tools ToolRegistry(config.tools) self.parser OutputParser() self.observer Observability(session_id) self.step_count 0 self.start_time time.time() self.recent_actions [] def run(self): context self.context_manager.initialize(self.task, self.memory) while self.should_continue(): self.step_count 1 response self.llm.call(context) self.observer.log_model_call(self.step_count, context, response) action self.parser.parse(response) if action.type final_answer: self.observer.log_completion(self.step_count, action) return action.content elif action.type tool_call: result self.tools.execute(action.tool_name, action.params) self.observer.log_tool_call(self.step_count, action, result) context self.context_manager.append(context, action, result) self.recent_actions.append(action.signature()) elif action.type parse_error: context self.context_manager.append_error(context, action) return self.handle_timeout()这段代码看起来简单但每个方法内部都有很多细节。比如should_continue方法要检查步数、耗时、重复动作三个条件。handle_timeout方法要生成一个合理的超时回复而不是直接报错。这些细节决定了 Agent 在实际使用中的体验。3.3 工具注册与调用的完整链路工具注册与调用的链路涉及 Tool Registry、Output Parser、LLM Integration 三个子系统的配合。完整流程是工具在启动时注册到 Tool Registry → Agent Loop 从 Tool Registry 获取工具定义 → 工具定义随上下文传给模型 → 模型输出工具调用意图 → Output Parser 解析出工具名和参数 → Tool Registry 执行工具 → 结果返回给 Agent Loop。注册环节我通常用一个装饰器来简化。装饰器接收工具的名称、描述、参数 schema把这些信息存到注册表的字典里同时保留函数引用。注册表还支持按标签过滤比如只获取read_only标签的工具用于查询类任务。调用环节的关键是参数校验和结果处理。参数校验用 JSON Schema 来验证模型输出的参数是否符合定义。如果不符合返回具体的错误信息比如“参数 start_date 格式错误应为 YYYY-MM-DD”。结果处理包括超时控制、异常捕获、结果截断。我一般会给每个工具设置独立的超时时间查询类 10 秒写入类 30 秒。异常捕获后把异常信息转成自然语言描述返回给模型。class ToolRegistry: def __init__(self, config): self.tools {} self.config config def register(self, name, description, parameters): def decorator(func): self.tools[name] { name: name, description: description, parameters: parameters, function: func, timeout: self.config.get(name, {}).get(timeout, 10) } return func return decorator def execute(self, name, params): if name not in self.tools: return {error: f工具 {name} 不存在} tool self.tools[name] # 参数校验 validation_error validate_params(params, tool[parameters]) if validation_error: return {error: validation_error} # 执行 try: with timeout(tool[timeout]): result tool[function](**params) return {result: truncate_result(result)} except TimeoutError: return {error: f工具 {name} 执行超时} except Exception as e: return {error: f工具 {name} 执行失败: {str(e)}}这个链路里最容易出问题的是工具描述的质量。如果描述写得太模糊模型会在不该调用的时候调用或者该调用的时候不调用。我的经验是工具描述要具体到“什么时候用”和“什么时候不用”。比如“查询销售数据”这个工具描述里要写“当用户询问销售相关数据时使用不适用于查询库存或用户信息”。3.4 记忆与上下文的协同工作记忆与上下文的协同是 Harness 里最需要调优的部分。我的做法是把 Memory System 和 Context Manager 分开实现但通过一个统一的接口来协作。Memory System 负责存储和检索Context Manager 负责组装和裁剪。工作记忆的更新发生在每一轮循环之后。Agent Loop 把模型输出和工具返回追加到工作记忆里。工作记忆用一个列表来维护每个元素包含角色模型/工具/用户、内容、时间戳。Context Manager 在组装上下文时从工作记忆里取最近 N 轮的内容加上系统提示词和工具定义再加上长期记忆的检索结果。会话记忆的加载发生在任务开始时。根据会话 ID 从缓存或数据库里读取历史摘要注入到工作记忆的开头。会话记忆的更新发生在任务结束时把本次任务的关键信息追加到会话摘要里。我一般会用一个小模型来生成摘要prompt 是“请用不超过 300 字总结以下任务的关键信息包括用户意图、执行的操作、得到的结果、未解决的问题”。长期记忆的检索发生在任务开始时和关键决策点。根据任务描述或当前上下文用向量检索找出最相关的几条长期记忆注入到工作记忆里。长期记忆的写入发生在任务成功完成后把可复用的经验总结存进去。我一般只存那些跨任务有复用价值的信息比如用户偏好、常用参数、成功模式。协同场景Memory 动作Context 动作触发时机任务开始加载会话摘要、检索长期记忆组装初始上下文Agent Loop 初始化每轮循环追加工作记忆裁剪并组装上下文每步执行后上下文超限提供历史内容触发摘要压缩token 超阈值任务结束更新会话摘要、写入长期记忆清理工作记忆任务完成或终止这里有一个调优经验摘要压缩的触发阈值不要设得太高。我一开始设的是窗口的 90%结果经常在压缩前就已经超限了。后来改成 75%给压缩留出足够的缓冲空间。另外摘要压缩不要一次性压太多每次压最早的两到三轮对话保留最近五轮完整内容这样模型对近期上下文的理解不会受影响。3.5 观测层的埋点与日志规范观测层的埋点要在搭建初期就做好不要等到出问题再补。我通常在 Agent Loop 的每个关键节点都加埋点循环开始、模型调用前后、工具调用前后、解析成功/失败、循环终止。每个埋点记录结构化数据包含任务 ID、步骤序号、事件类型、时间戳、耗时、关键字段。日志规范方面我要求所有日志都是 JSON 格式字段名统一。比如模型调用日志包含event: model_call、step、model_name、input_tokens、output_tokens、duration_ms、success。工具调用日志包含event: tool_call、step、tool_name、params、result_summary、duration_ms、success。这样后续做统计和可视化时非常方便。轨迹回放功能我一般用一个独立的查询接口来实现。给定任务 ID从日志系统里拉取所有相关日志按步骤序号排序然后格式化成可读的轨迹。关键步骤可以展开看详细的输入输出。这个功能在调试复杂任务时特别有用我经常用它来分析模型为什么走了某条路径。实操心得日志里记录模型输入输出时要注意脱敏和截断。我一般会对超过 2000 字符的内容做截断只保留前后各 500 字符中间用省略号代替。对于可能包含敏感信息的字段比如用户 ID、邮箱、手机号做掩码处理。这样既保留了调试价值又避免了数据泄露风险。4. 实际运行中的典型问题与排查技巧4.1 模型不调用工具或调用错误工具这是最常见的问题表现是模型在需要查数据的时候直接编造答案或者调用了不相关的工具。根本原因通常是工具描述不够清晰或者系统提示词里没有强调工具使用的优先级。排查思路是这样的先看日志里模型收到的工具定义是什么确认工具描述是否准确。然后看模型输出的原始文本判断它是完全没意识到有工具可用还是意识到了但选择了错误的工具。如果是前者检查工具定义是否真的传给了模型以及系统提示词里是否明确说了“需要外部信息时必须调用工具”。如果是后者优化工具描述把工具的使用场景写得更具体。我的经验是在系统提示词里加一句“当你不确定答案或需要实时数据时必须调用工具不要编造”能显著降低模型编造答案的概率。另外工具名称要起得直观比如query_sales_data比qs好得多模型更容易理解。症状可能原因排查方法解决措施完全不调用工具工具定义未传入检查日志中模型输入修复工具定义传递链路调用错误工具工具描述模糊对比工具描述与任务细化工具使用场景描述编造答案提示词未强调检查系统提示词加入强制调用工具的指令参数格式错误参数 schema 不清查看解析错误日志补充参数示例和格式说明4.2 上下文超限导致任务中断长任务跑到一半突然报 token 超限这是很常见的问题。根本原因是 Context Manager 的裁剪策略不够激进或者工具返回结果太长没有做截断。排查时先看日志里每一轮的 token 消耗趋势找到是哪一步开始膨胀的。如果是工具返回结果太长检查 Tool Registry 里的截断逻辑是否生效。如果是对话历史累积太多检查摘要压缩的触发阈值是否合理。我遇到过一种情况是摘要压缩本身消耗了大量 token因为摘要 prompt 里包含了完整的历史内容。后来改成只把最早的两轮对话拿出来压缩问题就解决了。解决措施包括降低摘要压缩的触发阈值、增大工具返回结果的截断力度、减少每轮传入的历史轮数、把系统提示词和工具定义做精简。我一般会把系统提示词控制在 500 token 以内工具定义每个控制在 100 token 以内。4.3 工具调用超时或失败工具调用失败的原因很多网络问题、权限问题、参数问题、目标服务不可用。排查时先看工具调用日志里的错误信息区分是超时、连接失败、还是业务错误。超时的话检查工具的超时设置是否合理以及目标服务的响应时间。连接失败检查网络配置和认证信息。业务错误检查参数是否符合目标服务的要求。我的处理策略是分级重试。对于网络类错误自动重试三次每次间隔指数退避。对于参数类错误把错误信息返回给模型让它修正参数通常一次就能改对。对于权限类错误直接中断并报告给用户因为重试也没用。对于目标服务不可用重试两次后降级到备用方案或返回错误。注意工具重试一定要设置上限否则可能陷入无限重试。我一般设置最多重试三次三次都失败就返回错误让 Agent Loop 决定下一步。同时要在日志里记录每次重试的原因和结果方便后续分析。4.4 模型输出格式解析失败Output Parser 解析失败的表现是 Agent Loop 收到parse_error然后要么重试要么中断。根本原因通常是模型没有按照要求的格式输出或者输出里包含了干扰内容。排查时先看模型输出的原始文本判断是格式完全错误还是部分错误。如果是完全错误检查系统提示词里是否明确规定了输出格式以及是否给了格式示例。如果是部分错误比如 JSON 里多了个逗号优化解析器的容错能力。我一般会在解析器里实现多级降级先尝试严格 JSON 解析失败后尝试宽松解析再失败尝试正则提取最后才返回解析错误。解决措施包括在系统提示词里加入格式示例、使用原生 function calling 减少格式错误、增强解析器的容错能力、解析失败时给模型一次修正机会。我的经验是加入格式示例后解析成功率能从 70% 提升到 95% 以上。4.5 死循环与重复动作死循环的表现是 Agent 反复执行同一个动作消耗大量 token 但任务没有进展。根本原因通常是模型陷入了某种思维定式或者工具返回的结果让模型误以为需要重试。排查时看日志里的动作序列找到重复的模式。如果是连续调用同一个工具且参数相同说明模型没有从上次结果里获得新信息。如果是反复在“思考”和“调用”之间切换但没有实际进展说明终止条件没有正确触发。解决措施包括加入重复动作检测连续三次相同动作就强制中断优化工具返回结果让模型能从中获得明确的成功或失败信号在系统提示词里加入“如果工具返回结果已经满足需求请直接给出最终答案”的指令。我一般还会设置一个最大步数硬限制比如 20 步超过就中断并返回当前状态。问题类型典型表现快速排查推荐解决死循环重复相同工具调用查看动作序列重复检测最大步数上下文超限token 报错中断查看 token 趋势摘要压缩结果截断工具失败错误信息返回查看工具日志分级重试降级解析失败parse_error查看原始输出格式示例容错解析模型编造无工具调用直接回答检查提示词强制工具调用指令5. 七个子系统之外还需要考虑什么5.1 安全边界与权限控制七个子系统搭起来之后Agent 基本能跑了但离“能放心用”还有距离。安全边界是我在第二个项目里才真正重视起来的。Agent 能调工具、能读文件、能发邮件这些能力如果被恶意利用或者因为模型判断失误而误用后果可能很严重。我的做法是在 Tool Registry 层加权限控制。每个工具注册时标注所需权限等级Agent Loop 在执行前检查当前会话是否具备该权限。比如“查询数据”是低权限“发送邮件”是中权限“删除记录”是高权限。高权限工具需要用户显式确认才能执行。确认的方式可以是在 Agent Loop 里插入一个ask_user动作让用户确认后再继续。另一个安全措施是输入输出过滤。用户输入里如果包含明显的注入攻击模式比如“忽略之前的指令”要在进入 Agent Loop 之前就拦截掉。工具返回的结果里如果包含敏感信息要在返回给模型之前做脱敏。这些过滤规则我一般放在一个独立的中间件里不混在业务逻辑里。5.2 成本控制与模型选择策略Agent 跑起来之后token 消耗速度往往超出预期。一个复杂任务跑 20 步每步平均 3000 token一次任务就是 6 万 token。如果用的是高价模型成本很快就上去了。成本控制需要在 Harness 层面做策略。我的策略是分级模型选择。简单任务用便宜的小模型复杂任务用能力强的大模型。判断任务复杂度可以在 Agent Loop 初始化时做一个快速评估根据任务描述的长度、涉及的工具数量、是否需要多步推理来打分。另一个策略是缓存重复调用。同样的工具调用如果参数相同结果可以直接从缓存里取不用重新执行。我一般会在 Tool Registry 层加一个 LRU 缓存对查询类工具的结果缓存 5 分钟。还有一个策略是动态调整上下文长度。不是每一轮都需要把完整历史传给模型简单步骤可以只传最近一轮。我一般会在 Context Manager 里根据当前步骤的复杂度动态决定传多少历史。简单工具调用只传最近一轮复杂推理传最近三轮。5.3 多 Agent 协作的扩展思路单个 Agent 的 Harness 搭好之后很自然会想到多 Agent 协作。比如一个 Agent 负责查数据一个 Agent 负责写报告一个 Agent 负责审核。多 Agent 协作的 Harness 需要在七个子系统之上再加一层编排层。编排层负责定义 Agent 之间的协作模式是串行还是并行是主从还是对等是竞争还是投票。我一般用主从模式一个主 Agent 负责任务分解和结果汇总多个从 Agent 负责执行子任务。主 Agent 的 Tool Registry 里注册的不是普通工具而是其他 Agent 的调用接口。从 Agent 的 Harness 和单 Agent 一样只是任务范围更窄。多 Agent 协作的难点在于状态同步和错误传播。从 Agent 失败了主 Agent 需要知道并决定是重试还是换方案。从 Agent 的结果需要汇总到主 Agent 的上下文里。我的做法是给每个从 Agent 定义一个标准的结果格式包含状态、摘要、详细数据。主 Agent 根据状态决定下一步根据摘要更新上下文。实操心得多 Agent 协作不要一上来就搞太复杂。我建议先从两个 Agent 的串行协作开始跑通之后再增加 Agent 数量和协作模式。每增加一个 Agent调试复杂度是指数级上升的。另外从 Agent 的任务描述要尽可能具体不要给模糊指令否则从 Agent 的行为会很难预测。5.4 从原型到生产的工程化差距原型阶段能跑通任务就算成功但生产环境的要求高得多。工程化差距主要体现在稳定性、可观测性、可维护性三个方面。稳定性方面原型可能跑十次成功八次生产要求跑一万次成功九千九百次。差距在于错误处理是否完备、重试策略是否合理、降级方案是否可用。我一般会在生产环境里加一个熔断机制某个工具连续失败超过阈值就暂时禁用避免拖垮整个任务。可观测性方面原型可能只打日志生产需要指标、追踪、告警。我一般会接入一个监控系统把任务成功率、平均步数、平均耗时、token 消耗这些指标做成仪表盘设置告警阈值。任务成功率低于 90% 就告警平均步数超过 15 就告警。可维护性方面原型可能代码写得很随意生产需要清晰的模块边界、配置管理、版本控制。我一般会把七个子系统做成独立的模块每个模块有明确的接口定义。配置全部外置不同环境用不同配置文件。每次修改都走代码审查和自动化测试。从原型到生产的迁移我的经验是不要试图一次性做到完美。先把最影响稳定性的部分补上比如错误处理和重试。然后补可观测性让问题能被发现。最后补可维护性让团队其他人能接手。这个顺序能让投入产出比最大化。