1. 从一次裸调用说起:为什么单次 LLM 调用撑不起一个 Agent很多人第一次接触 Agent 开发,都是从一段几十行的脚本开始的:拼一个 prompt,调一次模型接口,拿到返回文本,解析一下,结束。这个模式在 demo 阶段完全够用,但只要你想让它"自己干活"——比如读文件、查数据库、调外部接口、根据中间结果决定下一步——单次调用立刻就会露馅。原因很直接:单次 LLM 调用是无状态的、一次性的、没有反馈回路的。它不知道上一步做了什么,也没法根据执行结果调整策略,更没法在失败时重试。你让它"帮我分析这份日志并生成报告",它只能凭 prompt 里塞进去的那点上下文硬编,一旦日志超过上下文窗口,或者需要多轮工具调用,整个链路就断了。所以从"一次调用"到"完整 Agent",本质上是补三样东西:循环(Loop)、状态(State)、工具(Tool)。而把这三样东西工程化地组织起来的那层东西,就是标题里说的 Harness Runtime。Harness 这个词在测试领域是"测试夹具"的意思,在 Agent 语境里我更喜欢把它理解成"驾驭层"——它不负责模型本身有多聪明,它负责让模型的能力被稳定、可控、可观测地释放出来。这篇文章想聊的就是:当你决定认真做一个 Agent 系统时,架构到底该怎么选。是从最轻的 ReAct 循环起步,还是一上来就上多 Agent 编排?工具调用用原生 function calling 还是自己解析?状态放内存还是落盘?我会把每个选择的取舍逻辑讲清楚,再给出一套可以直接抄的 Harness Runtime 实现骨架。适合已经写过一两次模型调用、想往工程化方向走的人,也适合正在做技术选型、被各种框架绕晕的人。2. 架构选型的三条主线:循环、状态、工具在动手写代码之前,先把选型的坐标系建立起来。Agent 架构的复杂度,几乎全部由三个维度的决策决定,我把它们称为三条主线。理解这三条线,比记住任何框架的 API 都重要。2.1 循环主线:从单轮到多轮的演进路径最原始的形态是单轮调用:输入 prompt,输出结果,程序结束。它的适用场景非常窄——纯文本生成、分类、抽取这类"一问一答"任务。一旦任务需要"根据结果决定下一步",单轮就不够了。往上一级是ReAct 循环(Reasoning + Acting)。它的核心是一个 while 循环:模型输出思考(Thought)和动作(Action),运行时执行动作拿到观察结果(Observation),把 Observation 塞回上下文,再让模型继续思考,直到模型输出终止信号。这个模式的好处是极其简单,一个循环加一个工具分发器就能跑起来,而且对模型能力的要求相对低——只要模型能遵循"输出特定格式"的指令就行。再往上是带规划的多步循环。区别在于,模型不是每一步都重新决策,而是先生成一个计划(Plan),然后逐步执行,执行过程中可以修订计划。这种模式适合步骤多、依赖关系复杂的任务,比如"先查数据、再清洗、再建模、再出图"这种流水线。代价是 prompt 设计更复杂,而且计划一旦跑偏,纠偏成本更高。最高一级是多 Agent 协作。把不同职责拆给不同的 Agent 实例,比如一个负责规划、一个负责执行、一个负责审查。它们之间通过消息传递协作。这个模式听起来很美,但我要泼一盆冷水:绝大多数场景根本用不上多 Agent。多 Agent 带来的通信开销、状态同步复杂度、调试难度是成倍上升的,而收益往往只在"职责差异极大且需要互相制衡"的场景才体现得出来。我的建议很明确:从 ReAct 循环起步,把状态和工具做扎实,等真的遇到单 Agent 搞不定的瓶颈,再考虑往上加复杂度。不要因为框架宣传多 Agent 就一上来堆架构,那是给自己挖坑。2.2 状态主线:内存、会话、持久化三层状态管理是 Agent 工程化里最容易被低估的部分。很多人写 Agent 时把对话历史往一个 list 里一塞就完事,跑几十轮之后上下文爆炸,或者进程一重启全丢了。我把状态分成三层来看。第一层是工作内存(Working Memory),就是当前这一轮循环里模型能看到的东西:系统提示、历史消息、工具返回结果。这一层的关键是上下文窗口管理——你不能无限往里塞,必须有裁剪或摘要策略。第二层是会话状态(Session State),跨多轮对话但仍在一次会话生命周期内。比如用户之前说过的偏好、已经确认过的参数。这一层通常需要一个显式的状态对象,而不是靠翻聊天记录去猜。第三层是持久化状态(Persistent State),跨会话、跨进程。比如任务进度、长期记忆、用户画像。这一层要落盘,通常用数据库或文件系统。选型时的核心问题是:你的 Agent 需要记住多久的东西?如果只是单次任务,工作内存加轻量会话状态就够;如果是长期陪伴型或任务型 Agent,持久化层必须提前设计,否则后期迁移成本极高。2.3 工具主线:原生调用、文本解析、代码执行工具调用是 Agent 的"手脚"。目前主流有三种实现路径,各有取舍。原生 function calling:模型厂商在 API 层面支持结构化工具调用,你传一个工具 schema 列表,模型返回结构化的调用请求。优点是解析稳定、格式规范、模型经过专门训练。缺点是绑定厂商,换模型可能要改代码,而且部分开源模型的 function calling 支持并不完善。文本解析:让模型按约定格式(比如 JSON 或特定标记)输出工具调用,运行时用正则或解析器提取。优点是模型无关,任何能遵循指令的模型都能用。缺点是脆弱——模型偶尔会输出格式不对的东西,需要健壮的容错和重试。代码执行:让模型直接生成代码,运行时在沙箱里执行。优点是表达力最强,能处理任意复杂逻辑。缺点是安全风险高,必须严格沙箱隔离,而且调试困难。实操中我通常采用混合策略:优先用原生 function calling,同时保留文本解析作为兜底,代码执行只在明确需要且沙箱完备时启用。这样既享受了原生调用的稳定性,又不至于被单一厂商锁死。3. Harness Runtime 的核心组件拆解理解了三条主线,就可以动手设计 Harness Runtime 了。我把它拆成五个核心组件,每个组件解决一个明确的问题。这套拆法不绑定任何具体框架,你可以用任何语言实现。3.1 调度器:循环的心脏调度器负责驱动整个 ReAct 循环。它的伪代码逻辑大致是这样:def run(task, max_steps=20): context = build_initial_context(task) for step in range(max_steps): response = llm.invoke(context) action = parse_action(response) if action.type == "finish": return action.payload observation = execute_tool(action) context = append_observation(context, observation) return fallback_result(context)看起来简单,但魔鬼在细节里。max_steps 必须设上限,否则模型可能陷入死循环,一直调用同一个工具。我踩过的坑是:某次工具返回了空结果,模型以为没执行成功,反复重试同一个调用,烧掉大量 token。后来加了步数上限和"相同动作重复检测",才稳住。调度器还要处理异常传播。工具执行失败时,是把异常直接抛给模型让它自己想办法,还是运行时先做一层重试?我的经验是:可恢复的错误(网络抖动、临时超时)在运行时重试,不可恢复的错误(参数错误、权限不足)才反馈给模型。这样既减少了无效的模型调用,又让模型在真正需要决策时才介入。3.2 上下文管理器:别让窗口爆炸上下文管理器负责决定"每一轮模型能看到什么"。这是最考验工程功力的地方,因为上下文窗口是稀缺资源。我的策略是分层裁剪。系统提示永远保留;最近 N 轮对话完整保留;更早的历史做摘要压缩;工具返回的超长结果做截断或摘要。具体 N 取多少,取决于你的任务类型——需要强记忆的任务 N 大一些,纯工具调用型任务 N 可以小。这里有个反直觉的经验:不是塞得越多越好。上下文太长不仅贵,还会稀释模型的注意力,导致它忽略关键信息。我实测下来,把无关的历史摘要成一句话,比原样保留效果好得多。摘要本身可以用一次便宜的模型调用来做,成本远低于每轮都带着长上下文。还有一个细节是工具结果的格式化。工具返回的原始 JSON 往往很啰嗦,直接塞进上下文很浪费。我会在运行时做一层精简,只保留模型决策需要的字段。这一步能省下大量 token,而且让模型更容易抓住重点。3.3 工具注册表:统一接口与安全边界工具注册表管理所有可被调用的工具。每个工具需要声明:名称、描述、参数 schema、执行函数、超时时间、是否需要确认。描述字段特别重要,因为模型是靠描述来决定用哪个工具的。描述写得含糊,模型就会乱调。我见过太多人把工具描述写成"处理数据",结果模型根本不知道什么时候该用它。好的描述应该包含:这个工具做什么、什么时候用、参数含义、返回什么。宁可啰嗦一点,也别让模型猜。安全边界是另一个重点。工具执行前要做参数校验,防止模型生成越界参数(比如文件路径穿越、SQL 注入)。涉及写操作或外部调用的工具,最好加一层人工确认或权限检查。我个人的做法是给工具打标签:只读工具直接执行,写工具需要确认,危险工具默认禁用。3.4 记忆模块:短期与长期的分工记忆模块和上下文管理器容易混淆,我区分一下:上下文管理器管的是"这一轮能看到什么",记忆模块管的是"什么东西值得跨轮次、跨会话保留"。短期记忆就是当前任务的执行轨迹,通常放在会话状态里。长期记忆则是从历史交互中提炼出的、可复用的信息,比如用户偏好、常见问题的解决方案。长期记忆的写入需要提炼,不能把原始对话直接存进去,否则检索时全是噪音。检索策略上,小规模用关键词匹配就够,大规模再上向量检索。我见过有人一上来就搭向量库,结果数据量才几百条,纯属过度设计。先跑起来,等真的检索不动了再升级。3.5 可观测层:没有日志就没有调试Agent 系统最痛苦的就是调试——模型是黑盒,你不知道它为什么做了某个决策。可观测层就是解决这个的。至少要记录:每一轮的输入上下文、模型的原始输出、解析出的动作、工具执行结果、耗时、token 消耗。这些日志在排查问题时是救命稻草。我建议结构化日志,每条记录带上 step 编号和 trace id,方便串联。更进一步可以做回放:把一次完整执行的日志存下来,之后可以逐步重放,观察每一步的状态变化。这在定位"模型为什么在第 7 步跑偏"这类问题时极其有用。回放功能实现不难,但价值极高,强烈建议早期就加上。4. 从零搭一个最小可用 Harness:完整实操理论讲完了,现在动手。我会给出一套最小可用 Harness 的实现骨架,用 Python 写,但思路是语言无关的。你可以直接照着改。4.1 环境准备与依赖选择依赖尽量少。核心只需要一个模型客户端和一个 HTTP 库。我刻意不引入重型框架,因为框架会掩盖细节,而你需要理解细节。等你把这套手写版跑通了,再去用框架,你会知道框架在帮你做什么,出问题时也知道去哪找。pip install openai httpx pydantic用 pydantic 做参数校验,用 httpx 做工具里的外部调用。模型客户端按你实际用的厂商选,接口大同小异。4.2 定义工具接口与注册机制先定义工具的数据结构。每个工具是一个对象,包含元数据和执行函数:from pydantic import BaseModel from typing import Callable, Any class ToolSpec(BaseModel): name: str description: str parameters: dict handler: Callable timeout: int = 30 requires_confirmation: bool = False class ToolRegistry: def __init__(self): self._tools = {} def register(self, spec: ToolSpec): self._tools[spec.name] = spec def get(self, name: str) -> ToolSpec: return self._tools.get(name) def schemas(self): return [ { "name": t.name, "description": t.description, "parameters": t.parameters, } for t in self._tools.values() ]注册一个工具时,描述要写清楚。比如一个读文件的工具:registry.register(ToolSpec( name="read_file", description="读取指定路径的文本文件内容。当需要查看文件内容时使用。参数 path 为绝对路径。", parameters={ "type": "object", "properties": { "path": {"type": "string", "description": "文件的绝对路径"} }, "required": ["path"] }, handler=read_file_handler, ))注意描述里明确说了"什么时候用"和"参数含义",这是给模型看的说明书。4.3 实现 ReAct 循环与动作解析动作解析是循环的关键。如果用原生 function calling,模型返回的是结构化的 tool_calls,直接取就行。如果用文本解析,就要写一个健壮的解析器:import json import re def parse_action(response_text: str): # 尝试提取 JSON 块 match = re.search(r'\{.*\}', response_text, re.DOTALL) if not match: return {"type": "finish", "payload": response_text} try: data = json.loads(match.group()) except json.JSONDecodeError: return {"type": "finish", "payload": response_text} if "tool" in data: return {"type": "tool", "name": data["tool"], "args": data.get("args", {})} return {"type": "finish", "payload": data.get("answer", response_text)}这个解析器很粗糙,实际用的时候要加更多容错。但核心思路是:解析失败时不要崩,退化成 finish,把原始输出返回。这样至少不会让整个循环挂掉。循环主体:def run_agent(task, registry, llm, max_steps=20): context = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": task}] for step in range(max_steps): response = llm.invoke(context, tools=registry.schemas()) action = parse_action(response) if action["type"] == "finish": return action["payload"] tool = registry.get(action["name"]) if not tool: context.append({"role": "user", "content": f"未知工具 {action['name']}"}) continue try: result = tool.handler(**action["args"]) except Exception as e: result = f"工具执行失败: {e}" context.append({"role": "assistant", "content": response}) context.append({"role": "user", "content": f"观察结果: {result}"}) return "达到最大步数限制,任务未完成"4.4 上下文裁剪与 token 预算控制上面这个循环跑几轮之后上下文就会膨胀。加一个裁剪函数:def trim_context(context, max_tokens=8000): # 保留 system 和最近的消息,超出的做摘要 system = context[0] rest = context[1:] # 简单策略:保留最近 10 条 if len(rest) > 10: old = rest[:-10] recent = rest[-10:] summary = summarize(old) # 调用一次便宜模型做摘要 return [system, {"role": "user", "content": f"历史摘要: {summary}"}] + recent return contexttoken 预算要动态算。粗略估算:中文一个字约 1.5 token,英文一个词约 1.3 token。更准的做法是用对应模型的分词器。预算要留出输出空间,别把窗口塞满导致模型没地方生成。4.5 加一层可观测:日志与回放最后加上日志。每一步都记录:import logging import time logger = logging.getLogger("agent") def log_step(step, context, response, action, result, elapsed): logger.info({ "step": step, "context_len": len(str(context)), "response": response[:500], "action": action, "result": str(result)[:500], "elapsed": elapsed, })把日志写成 JSON 行格式,方便后续用工具分析。回放功能就是把这些日志按 step 顺序读出来,逐步重建状态。实现不难,但需要你在设计时就保证每一步的状态是可序列化的。5. 踩坑实录:那些文档不会告诉你的问题这套东西我前前后后改了好几版,踩的坑足够写一篇避雷指南。挑几个最有代表性的说说。5.1 模型不按格式输出怎么办这是文本解析路线最常见的坑。模型有时候会加一堆解释性文字,有时候 JSON 少个括号,有时候干脆用中文标点。我的应对是三层防御:第一层,prompt 里用 few-shot 示例强化格式;第二层,解析器做多种格式尝试(先试 JSON,再试代码块,再试正则);第三层,解析失败时把错误信息反馈给模型,让它重新输出。实测下来,第三层最有效。模型看到"你的输出格式不对,请只输出 JSON"之后,通常第二次就对了。但要注意重试次数上限,别陷入无限重试。5.2 工具调用陷入死循环前面提过,工具返回空结果时模型会反复重试。除了步数上限,我还加了一个动作指纹机制:把每次动作的名称和参数哈希一下,如果连续三次相同,就强制中断并提示模型换策略。另一个隐蔽的死循环是两个工具互相调用。比如 A 工具的结果触发 B,B 的结果又触发 A。这种要靠步数上限兜底,同时在工具描述里写清楚使用边界。5.3 上下文爆炸与摘要失真上下文裁剪做不好,要么爆窗口,要么摘要丢关键信息。我的经验是摘要要保留结构化信息,比如"用户确认了参数 X=1,Y=2"这种,而不是笼统的"用户提供了一些参数"。摘要 prompt 里要明确要求保留数字、路径、ID 这类关键实体。还有一个技巧是给摘要打时间戳,让模型知道哪些信息是旧的。旧信息在决策时权重应该更低。5.4 工具执行超时与异常处理外部工具调用一定要设超时。我见过因为一个 HTTP 请求卡住,整个 Agent 挂起的情况。超时之后要返回明确的错误信息给模型,而不是抛异常中断循环。异常处理的原则是:运行时能处理的就别麻烦模型,运行时处理不了的才反馈给模型。网络重试、临时故障这类,运行时自己扛;参数错误、逻辑冲突这类,反馈给模型让它重新决策。5.5 多轮对话中的状态污染如果 Agent 支持多轮对话,要小心状态污染。上一轮的工具结果如果还留在上下文里,可能干扰这一轮的决策。我的做法是每轮对话开始时清理工作内存,只保留会话状态。会话状态里存的是提炼过的信息,不是原始的工具输出。6. 常见问题速查与排查思路把上面这些坑整理成一张速查表,方便你遇到问题时快速定位。现象可能原因排查方向解决思路模型不调用工具,直接回答工具描述不清 / prompt 没强调检查工具描述是否说明使用场景补充 few-shot 示例,强化格式要求反复调用同一工具工具返回空 / 模型没理解结果看日志里工具返回内容加动作指纹检测,反馈明确错误上下文超限报错裁剪策略缺失统计每轮上下文长度加分层裁剪和摘要工具执行卡住无超时设置检查工具 handler所有外部调用加超时多轮对话答非所问状态污染检查上下文是否残留旧结果每轮清理工作内存token 消耗异常高上下文冗余分析日志里的 context_len精简工具返回,压缩历史模型输出格式错误解析器太脆弱看原始输出多层解析 + 重试反馈排查的核心方法是看日志。没有日志的 Agent 调试就是盲人摸象。我建议在开发阶段把日志级别调到最详细,上线后再按需降低。7. 架构演进:什么时候该往上加复杂度最后聊聊演进路径。很多人纠结"我该用哪个框架""要不要上多 Agent",其实答案取决于你当前卡在哪。如果你还在单次调用阶段,先把它改成 ReAct 循环,这是收益最大的一步。如果你已经有循环但状态管理混乱,先把状态三层分清楚。如果你状态清晰但工具调用不稳定,先把工具注册表和解析器做扎实。每一步都解决一个具体的痛点,而不是为了架构而架构。多 Agent 的引入时机,我的判断标准是:当单个 Agent 的职责已经明显分裂,且不同职责需要不同的模型、不同的工具集、不同的提示策略时,才考虑拆分。如果只是任务步骤多,那用规划型单 Agent 就够了,没必要拆。框架的选择也是同理。手写版跑通之后,你会发现大部分框架帮你做的就是本文讲的这些事。到那时你再去选框架,就能判断哪个框架的设计符合你的需求,而不是被框架的营销话术牵着走。我个人在实际项目中的体会是:Agent 的难点从来不在模型有多强,而在工程细节有多稳。循环的边界、状态的清理、工具的容错、日志的完备,这些看起来不起眼的东西,才是决定一个 Agent 能不能上生产的关键。模型能力会随着时间提升,但这些工程基本功,得靠自己一点点磨。
1. 从一次裸调用说起:为什么单次 LLM 调用撑不起一个 Agent很多人第一次接触 Agent 开发,都是从一段几十行的脚本开始的:拼一个 prompt,调一次模型接口,拿到返回文本,解析一下,结束。这个模式在 demo 阶段完全够用,但只要你想让它"自己干活"——比如读文件、查数据库、调外部接口、根据中间结果决定下一步——单次调用立刻就会露馅。原因很直接:单次 LLM 调用是无状态的、一次性的、没有反馈回路的。它不知道上一步做了什么,也没法根据执行结果调整策略,更没法在失败时重试。你让它"帮我分析这份日志并生成报告",它只能凭 prompt 里塞进去的那点上下文硬编,一旦日志超过上下文窗口,或者需要多轮工具调用,整个链路就断了。所以从"一次调用"到"完整 Agent",本质上是补三样东西:循环(Loop)、状态(State)、工具(Tool)。而把这三样东西工程化地组织起来的那层东西,就是标题里说的 Harness Runtime。Harness 这个词在测试领域是"测试夹具"的意思,在 Agent 语境里我更喜欢把它理解成"驾驭层"——它不负责模型本身有多聪明,它负责让模型的能力被稳定、可控、可观测地释放出来。这篇文章想聊的就是:当你决定认真做一个 Agent 系统时,架构到底该怎么选。是从最轻的 ReAct 循环起步,还是一上来就上多 Agent 编排?工具调用用原生 function calling 还是自己解析?状态放内存还是落盘?我会把每个选择的取舍逻辑讲清楚,再给出一套可以直接抄的 Harness Runtime 实现骨架。适合已经写过一两次模型调用、想往工程化方向走的人,也适合正在做技术选型、被各种框架绕晕的人。2. 架构选型的三条主线:循环、状态、工具在动手写代码之前,先把选型的坐标系建立起来。Agent 架构的复杂度,几乎全部由三个维度的决策决定,我把它们称为三条主线。理解这三条线,比记住任何框架的 API 都重要。2.1 循环主线:从单轮到多轮的演进路径最原始的形态是单轮调用:输入 prompt,输出结果,程序结束。它的适用场景非常窄——纯文本生成、分类、抽取这类"一问一答"任务。一旦任务需要"根据结果决定下一步",单轮就不够了。往上一级是ReAct 循环(Reasoning + Acting)。它的核心是一个 while 循环:模型输出思考(Thought)和动作(Action),运行时执行动作拿到观察结果(Observation),把 Observation 塞回上下文,再让模型继续思考,直到模型输出终止信号。这个模式的好处是极其简单,一个循环加一个工具分发器就能跑起来,而且对模型能力的要求相对低——只要模型能遵循"输出特定格式"的指令就行。再往上是带规划的多步循环。区别在于,模型不是每一步都重新决策,而是先生成一个计划(Plan),然后逐步执行,执行过程中可以修订计划。这种模式适合步骤多、依赖关系复杂的任务,比如"先查数据、再清洗、再建模、再出图"这种流水线。代价是 prompt 设计更复杂,而且计划一旦跑偏,纠偏成本更高。最高一级是多 Agent 协作。把不同职责拆给不同的 Agent 实例,比如一个负责规划、一个负责执行、一个负责审查。它们之间通过消息传递协作。这个模式听起来很美,但我要泼一盆冷水:绝大多数场景根本用不上多 Agent。多 Agent 带来的通信开销、状态同步复杂度、调试难度是成倍上升的,而收益往往只在"职责差异极大且需要互相制衡"的场景才体现得出来。我的建议很明确:从 ReAct 循环起步,把状态和工具做扎实,等真的遇到单 Agent 搞不定的瓶颈,再考虑往上加复杂度。不要因为框架宣传多 Agent 就一上来堆架构,那是给自己挖坑。2.2 状态主线:内存、会话、持久化三层状态管理是 Agent 工程化里最容易被低估的部分。很多人写 Agent 时把对话历史往一个 list 里一塞就完事,跑几十轮之后上下文爆炸,或者进程一重启全丢了。我把状态分成三层来看。第一层是工作内存(Working Memory),就是当前这一轮循环里模型能看到的东西:系统提示、历史消息、工具返回结果。这一层的关键是上下文窗口管理——你不能无限往里塞,必须有裁剪或摘要策略。第二层是会话状态(Session State),跨多轮对话但仍在一次会话生命周期内。比如用户之前说过的偏好、已经确认过的参数。这一层通常需要一个显式的状态对象,而不是靠翻聊天记录去猜。第三层是持久化状态(Persistent State),跨会话、跨进程。比如任务进度、长期记忆、用户画像。这一层要落盘,通常用数据库或文件系统。选型时的核心问题是:你的 Agent 需要记住多久的东西?如果只是单次任务,工作内存加轻量会话状态就够;如果是长期陪伴型或任务型 Agent,持久化层必须提前设计,否则后期迁移成本极高。2.3 工具主线:原生调用、文本解析、代码执行工具调用是 Agent 的"手脚"。目前主流有三种实现路径,各有取舍。原生 function calling:模型厂商在 API 层面支持结构化工具调用,你传一个工具 schema 列表,模型返回结构化的调用请求。优点是解析稳定、格式规范、模型经过专门训练。缺点是绑定厂商,换模型可能要改代码,而且部分开源模型的 function calling 支持并不完善。文本解析:让模型按约定格式(比如 JSON 或特定标记)输出工具调用,运行时用正则或解析器提取。优点是模型无关,任何能遵循指令的模型都能用。缺点是脆弱——模型偶尔会输出格式不对的东西,需要健壮的容错和重试。代码执行:让模型直接生成代码,运行时在沙箱里执行。优点是表达力最强,能处理任意复杂逻辑。缺点是安全风险高,必须严格沙箱隔离,而且调试困难。实操中我通常采用混合策略:优先用原生 function calling,同时保留文本解析作为兜底,代码执行只在明确需要且沙箱完备时启用。这样既享受了原生调用的稳定性,又不至于被单一厂商锁死。3. Harness Runtime 的核心组件拆解理解了三条主线,就可以动手设计 Harness Runtime 了。我把它拆成五个核心组件,每个组件解决一个明确的问题。这套拆法不绑定任何具体框架,你可以用任何语言实现。3.1 调度器:循环的心脏调度器负责驱动整个 ReAct 循环。它的伪代码逻辑大致是这样:def run(task, max_steps=20): context = build_initial_context(task) for step in range(max_steps): response = llm.invoke(context) action = parse_action(response) if action.type == "finish": return action.payload observation = execute_tool(action) context = append_observation(context, observation) return fallback_result(context)看起来简单,但魔鬼在细节里。max_steps 必须设上限,否则模型可能陷入死循环,一直调用同一个工具。我踩过的坑是:某次工具返回了空结果,模型以为没执行成功,反复重试同一个调用,烧掉大量 token。后来加了步数上限和"相同动作重复检测",才稳住。调度器还要处理异常传播。工具执行失败时,是把异常直接抛给模型让它自己想办法,还是运行时先做一层重试?我的经验是:可恢复的错误(网络抖动、临时超时)在运行时重试,不可恢复的错误(参数错误、权限不足)才反馈给模型。这样既减少了无效的模型调用,又让模型在真正需要决策时才介入。3.2 上下文管理器:别让窗口爆炸上下文管理器负责决定"每一轮模型能看到什么"。这是最考验工程功力的地方,因为上下文窗口是稀缺资源。我的策略是分层裁剪。系统提示永远保留;最近 N 轮对话完整保留;更早的历史做摘要压缩;工具返回的超长结果做截断或摘要。具体 N 取多少,取决于你的任务类型——需要强记忆的任务 N 大一些,纯工具调用型任务 N 可以小。这里有个反直觉的经验:不是塞得越多越好。上下文太长不仅贵,还会稀释模型的注意力,导致它忽略关键信息。我实测下来,把无关的历史摘要成一句话,比原样保留效果好得多。摘要本身可以用一次便宜的模型调用来做,成本远低于每轮都带着长上下文。还有一个细节是工具结果的格式化。工具返回的原始 JSON 往往很啰嗦,直接塞进上下文很浪费。我会在运行时做一层精简,只保留模型决策需要的字段。这一步能省下大量 token,而且让模型更容易抓住重点。3.3 工具注册表:统一接口与安全边界工具注册表管理所有可被调用的工具。每个工具需要声明:名称、描述、参数 schema、执行函数、超时时间、是否需要确认。描述字段特别重要,因为模型是靠描述来决定用哪个工具的。描述写得含糊,模型就会乱调。我见过太多人把工具描述写成"处理数据",结果模型根本不知道什么时候该用它。好的描述应该包含:这个工具做什么、什么时候用、参数含义、返回什么。宁可啰嗦一点,也别让模型猜。安全边界是另一个重点。工具执行前要做参数校验,防止模型生成越界参数(比如文件路径穿越、SQL 注入)。涉及写操作或外部调用的工具,最好加一层人工确认或权限检查。我个人的做法是给工具打标签:只读工具直接执行,写工具需要确认,危险工具默认禁用。3.4 记忆模块:短期与长期的分工记忆模块和上下文管理器容易混淆,我区分一下:上下文管理器管的是"这一轮能看到什么",记忆模块管的是"什么东西值得跨轮次、跨会话保留"。短期记忆就是当前任务的执行轨迹,通常放在会话状态里。长期记忆则是从历史交互中提炼出的、可复用的信息,比如用户偏好、常见问题的解决方案。长期记忆的写入需要提炼,不能把原始对话直接存进去,否则检索时全是噪音。检索策略上,小规模用关键词匹配就够,大规模再上向量检索。我见过有人一上来就搭向量库,结果数据量才几百条,纯属过度设计。先跑起来,等真的检索不动了再升级。3.5 可观测层:没有日志就没有调试Agent 系统最痛苦的就是调试——模型是黑盒,你不知道它为什么做了某个决策。可观测层就是解决这个的。至少要记录:每一轮的输入上下文、模型的原始输出、解析出的动作、工具执行结果、耗时、token 消耗。这些日志在排查问题时是救命稻草。我建议结构化日志,每条记录带上 step 编号和 trace id,方便串联。更进一步可以做回放:把一次完整执行的日志存下来,之后可以逐步重放,观察每一步的状态变化。这在定位"模型为什么在第 7 步跑偏"这类问题时极其有用。回放功能实现不难,但价值极高,强烈建议早期就加上。4. 从零搭一个最小可用 Harness:完整实操理论讲完了,现在动手。我会给出一套最小可用 Harness 的实现骨架,用 Python 写,但思路是语言无关的。你可以直接照着改。4.1 环境准备与依赖选择依赖尽量少。核心只需要一个模型客户端和一个 HTTP 库。我刻意不引入重型框架,因为框架会掩盖细节,而你需要理解细节。等你把这套手写版跑通了,再去用框架,你会知道框架在帮你做什么,出问题时也知道去哪找。pip install openai httpx pydantic用 pydantic 做参数校验,用 httpx 做工具里的外部调用。模型客户端按你实际用的厂商选,接口大同小异。4.2 定义工具接口与注册机制先定义工具的数据结构。每个工具是一个对象,包含元数据和执行函数:from pydantic import BaseModel from typing import Callable, Any class ToolSpec(BaseModel): name: str description: str parameters: dict handler: Callable timeout: int = 30 requires_confirmation: bool = False class ToolRegistry: def __init__(self): self._tools = {} def register(self, spec: ToolSpec): self._tools[spec.name] = spec def get(self, name: str) -> ToolSpec: return self._tools.get(name) def schemas(self): return [ { "name": t.name, "description": t.description, "parameters": t.parameters, } for t in self._tools.values() ]注册一个工具时,描述要写清楚。比如一个读文件的工具:registry.register(ToolSpec( name="read_file", description="读取指定路径的文本文件内容。当需要查看文件内容时使用。参数 path 为绝对路径。", parameters={ "type": "object", "properties": { "path": {"type": "string", "description": "文件的绝对路径"} }, "required": ["path"] }, handler=read_file_handler, ))注意描述里明确说了"什么时候用"和"参数含义",这是给模型看的说明书。4.3 实现 ReAct 循环与动作解析动作解析是循环的关键。如果用原生 function calling,模型返回的是结构化的 tool_calls,直接取就行。如果用文本解析,就要写一个健壮的解析器:import json import re def parse_action(response_text: str): # 尝试提取 JSON 块 match = re.search(r'\{.*\}', response_text, re.DOTALL) if not match: return {"type": "finish", "payload": response_text} try: data = json.loads(match.group()) except json.JSONDecodeError: return {"type": "finish", "payload": response_text} if "tool" in data: return {"type": "tool", "name": data["tool"], "args": data.get("args", {})} return {"type": "finish", "payload": data.get("answer", response_text)}这个解析器很粗糙,实际用的时候要加更多容错。但核心思路是:解析失败时不要崩,退化成 finish,把原始输出返回。这样至少不会让整个循环挂掉。循环主体:def run_agent(task, registry, llm, max_steps=20): context = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": task}] for step in range(max_steps): response = llm.invoke(context, tools=registry.schemas()) action = parse_action(response) if action["type"] == "finish": return action["payload"] tool = registry.get(action["name"]) if not tool: context.append({"role": "user", "content": f"未知工具 {action['name']}"}) continue try: result = tool.handler(**action["args"]) except Exception as e: result = f"工具执行失败: {e}" context.append({"role": "assistant", "content": response}) context.append({"role": "user", "content": f"观察结果: {result}"}) return "达到最大步数限制,任务未完成"4.4 上下文裁剪与 token 预算控制上面这个循环跑几轮之后上下文就会膨胀。加一个裁剪函数:def trim_context(context, max_tokens=8000): # 保留 system 和最近的消息,超出的做摘要 system = context[0] rest = context[1:] # 简单策略:保留最近 10 条 if len(rest) > 10: old = rest[:-10] recent = rest[-10:] summary = summarize(old) # 调用一次便宜模型做摘要 return [system, {"role": "user", "content": f"历史摘要: {summary}"}] + recent return contexttoken 预算要动态算。粗略估算:中文一个字约 1.5 token,英文一个词约 1.3 token。更准的做法是用对应模型的分词器。预算要留出输出空间,别把窗口塞满导致模型没地方生成。4.5 加一层可观测:日志与回放最后加上日志。每一步都记录:import logging import time logger = logging.getLogger("agent") def log_step(step, context, response, action, result, elapsed): logger.info({ "step": step, "context_len": len(str(context)), "response": response[:500], "action": action, "result": str(result)[:500], "elapsed": elapsed, })把日志写成 JSON 行格式,方便后续用工具分析。回放功能就是把这些日志按 step 顺序读出来,逐步重建状态。实现不难,但需要你在设计时就保证每一步的状态是可序列化的。5. 踩坑实录:那些文档不会告诉你的问题这套东西我前前后后改了好几版,踩的坑足够写一篇避雷指南。挑几个最有代表性的说说。5.1 模型不按格式输出怎么办这是文本解析路线最常见的坑。模型有时候会加一堆解释性文字,有时候 JSON 少个括号,有时候干脆用中文标点。我的应对是三层防御:第一层,prompt 里用 few-shot 示例强化格式;第二层,解析器做多种格式尝试(先试 JSON,再试代码块,再试正则);第三层,解析失败时把错误信息反馈给模型,让它重新输出。实测下来,第三层最有效。模型看到"你的输出格式不对,请只输出 JSON"之后,通常第二次就对了。但要注意重试次数上限,别陷入无限重试。5.2 工具调用陷入死循环前面提过,工具返回空结果时模型会反复重试。除了步数上限,我还加了一个动作指纹机制:把每次动作的名称和参数哈希一下,如果连续三次相同,就强制中断并提示模型换策略。另一个隐蔽的死循环是两个工具互相调用。比如 A 工具的结果触发 B,B 的结果又触发 A。这种要靠步数上限兜底,同时在工具描述里写清楚使用边界。5.3 上下文爆炸与摘要失真上下文裁剪做不好,要么爆窗口,要么摘要丢关键信息。我的经验是摘要要保留结构化信息,比如"用户确认了参数 X=1,Y=2"这种,而不是笼统的"用户提供了一些参数"。摘要 prompt 里要明确要求保留数字、路径、ID 这类关键实体。还有一个技巧是给摘要打时间戳,让模型知道哪些信息是旧的。旧信息在决策时权重应该更低。5.4 工具执行超时与异常处理外部工具调用一定要设超时。我见过因为一个 HTTP 请求卡住,整个 Agent 挂起的情况。超时之后要返回明确的错误信息给模型,而不是抛异常中断循环。异常处理的原则是:运行时能处理的就别麻烦模型,运行时处理不了的才反馈给模型。网络重试、临时故障这类,运行时自己扛;参数错误、逻辑冲突这类,反馈给模型让它重新决策。5.5 多轮对话中的状态污染如果 Agent 支持多轮对话,要小心状态污染。上一轮的工具结果如果还留在上下文里,可能干扰这一轮的决策。我的做法是每轮对话开始时清理工作内存,只保留会话状态。会话状态里存的是提炼过的信息,不是原始的工具输出。6. 常见问题速查与排查思路把上面这些坑整理成一张速查表,方便你遇到问题时快速定位。现象可能原因排查方向解决思路模型不调用工具,直接回答工具描述不清 / prompt 没强调检查工具描述是否说明使用场景补充 few-shot 示例,强化格式要求反复调用同一工具工具返回空 / 模型没理解结果看日志里工具返回内容加动作指纹检测,反馈明确错误上下文超限报错裁剪策略缺失统计每轮上下文长度加分层裁剪和摘要工具执行卡住无超时设置检查工具 handler所有外部调用加超时多轮对话答非所问状态污染检查上下文是否残留旧结果每轮清理工作内存token 消耗异常高上下文冗余分析日志里的 context_len精简工具返回,压缩历史模型输出格式错误解析器太脆弱看原始输出多层解析 + 重试反馈排查的核心方法是看日志。没有日志的 Agent 调试就是盲人摸象。我建议在开发阶段把日志级别调到最详细,上线后再按需降低。7. 架构演进:什么时候该往上加复杂度最后聊聊演进路径。很多人纠结"我该用哪个框架""要不要上多 Agent",其实答案取决于你当前卡在哪。如果你还在单次调用阶段,先把它改成 ReAct 循环,这是收益最大的一步。如果你已经有循环但状态管理混乱,先把状态三层分清楚。如果你状态清晰但工具调用不稳定,先把工具注册表和解析器做扎实。每一步都解决一个具体的痛点,而不是为了架构而架构。多 Agent 的引入时机,我的判断标准是:当单个 Agent 的职责已经明显分裂,且不同职责需要不同的模型、不同的工具集、不同的提示策略时,才考虑拆分。如果只是任务步骤多,那用规划型单 Agent 就够了,没必要拆。框架的选择也是同理。手写版跑通之后,你会发现大部分框架帮你做的就是本文讲的这些事。到那时你再去选框架,就能判断哪个框架的设计符合你的需求,而不是被框架的营销话术牵着走。我个人在实际项目中的体会是:Agent 的难点从来不在模型有多强,而在工程细节有多稳。循环的边界、状态的清理、工具的容错、日志的完备,这些看起来不起眼的东西,才是决定一个 Agent 能不能上生产的关键。模型能力会随着时间提升,但这些工程基本功,得靠自己一点点磨。