做 LLM 应用开发这几年,我被问得最多的一个问题就是:“Agent 架构到底怎么选?” 而这个问题的背后,往往还藏着一个更深的困惑——有人只需要一次函数调用级别的 LLM 封装,有人却在规划一套完整的 Harness Runtime。这两件事的技术栈、抽象层次和工程复杂度完全是两个量级,但很多团队在立项之初就把它们混为一谈,导致要么过度设计,要么后面推倒重来。
我个人的观点很简单:Agent 架构不是“选一个框架”这么简单,它本质上是在设计一个运行时。这个运行时承载着模型调用、工具执行、状态流转、记忆读写和观测反馈。你选什么方案,取决于你想让这个运行时承担多少责任。这篇文章我想从一次最朴素的 LLM 调用开始,一路拆到完整 Harness Runtime 的落地形态,把我这几轮项目里踩过的坑、验证过的方案和最终沉淀下来的架构判断一并整理出来。
1. 架构选择的本质:你其实在设计一个运行时
1.1 “Agent 化”和“调用 API”之间的真正分水岭
先看一个最朴素的场景。你写一段代码,把用户问题塞进模型上下文,拿到结果返回,这只是一次 LLM 调用。所有人在第一周做 Demo 时都是这么干的,不管用的是对话补全接口还是结构化输出接口。但从哪一刻起,它开始变成一个“Agent”了呢?
我认为分水岭不在于模型是否被允许“多轮对话”,而在于系统是否拥有了“自主决策下一步动作”的能力。一个真正的 Agent 循环,至少要包含四个环节:感知输入、规划动作、执行工具、观察结果,然后带着新信息重新进入下一轮。这个循环只要跑起来,你就被迫面对一个之前写 Demo 时完全不用考虑的问题——模型返回的答案不再只是最终输出,而可能是对某个工具的调用请求。这个“调用请求”怎么解析、怎么校验、怎么执行、怎么回填,就成了整个架构的第一块基石。
我见过不少团队一开始直接把响应文本塞给函数解析器,靠正则匹配工具名,结果模型只要稍微换个措辞,整个链路就崩了。这就是没有搞清楚“Agent 化”意味着什么:你不是在解析一段文本,而是在解析一个由模型生成的、符合某种契约的“动作指令”。架构设计的第一要务,就是把这层契约固化下来。
1.2 先厘清三个底层认知,再谈选型
在进入具体方案之前,有几个认知特别影响架构决策。第一个是“模型是运行时的一部分,不是外部依赖”。很多人把模型当作一个被调用的 API,但在 Agent 架构里,模型的行为模式、上下文窗口、工具调用稳定性,直接决定着你整个运行时应该怎么设计。比如你选的模型工具调用能力若不稳定,你可能就要在提示词工程和输出校验层投入更多精力;模型上下文如果偏短,你就得在记忆压缩上做更多文章。
第二个认知是“状态是 Agent 系统的第一公民”。一次普通的 LLM 调用是无状态的,你给我输入,我还你输出。但 Agent 系统每执行一步,都会产生新的中间状态——当前目标、已完成动作、待确认信息、工具返回值、历史决策轨迹。这些状态不仅要被保存,还要能被随时读取、分支和回滚。我见过很多团队在原型阶段把所有状态塞在内存里的一个字典里,等要接数据库、要支持并发、要支持人工介入审批时,才发现整个架构根本没有为状态演化留空间。
第三个认知是“Agent 架构选型本质上是复杂度转移”。不同架构方案不会消灭复杂度,只会把复杂度从一个地方转移到另一个地方。原生循环方案把复杂度转移到提示词工程和异常处理;工作流编排方案把复杂度转移到流程抽象和分支跳转逻辑;图状态机方案把复杂度转移到状态定义和转移条件的精确性上。所以选型不是在找“最简单的方案”,而是在找“你团队最擅长驾驭的那种复杂度”。
这三个认知确定了,再去看具体的技术选型,思路会清晰很多。下面我从最小闭环开始,逐步铺开。
2. 从一次调用到最小 Agent 循环:原语层设计与落地
2.1 模型调用封装:统一出入口,把所有杂活收敛进来
不管最终架构有多复杂,所有 Agent 系统的地基都是一层模型调用封装。这个封装不是简单地包一层 SDK,而是要承担几个关键职责:统一的上下文组装入口、重复消息压缩策略、工具定义注入、以及响应格式的强制校验。
我习惯把这一层设计成一个独立模块,至少包含这几个方法:run()负责执行单次模型调用,stream()负责流式响应,build_messages()负责把系统提示词、历史记录、工具返回结果和当前用户输入组装成模型协议要求的消息体。所有底层 SDK 的细节都只在这一层出现,上层永远不直接碰模型协议。
class ModelGateway: def __init__(self, model_cfg: ModelConfig, tool_schemas: list[dict]): self.model_cfg = model_cfg self.tool_schemas = tool_schemas def run(self, messages: list[dict], tools: bool = True) -> ModelResponse: payload = self._build_payload(messages, tools) raw = self._invoke(payload) return self._parse(raw) def stream(self, messages: list[dict], tools: bool = True): # 流式场景下需要处理增量消息合并,这里不多展开 ...你可能会觉得,这不就是套了个壳吗?但恰恰是这层壳,决定了后面接记忆系统、接工具回调、接日志追踪时是顺滑还是痛苦。如果所有调用都直接散落在业务代码里,等你想统一加个超时重试、想统一记录 token 消耗、想统一拦截敏感信息时,就会发现自己要改几十个地方。
另外我强烈建议在这一层就做“响应值对象”的抽象。模型返回的内容应该被标准化成一个统一结构,包含文本内容、工具调用列表、结束原因、token 统计等字段,而不是直接把原始 JSON 抛给上层。后续不管换模型还是换协议,上层代码都感知不到变化。
2.2 工具注册与执行:契约比实现更重要
工具是 Agent 的能力边界。架构设计里最容易翻车的点,是工具调用链路没设计好,导致模型在“规划”时无法准确判断该用哪个工具、参数怎么填。这一块的关键不在工具本身怎么实现,而在工具和模型之间的契约是否清晰。
一个标准的工具定义,至少要包含名称、描述、参数 JSON Schema。很多人会忽略描述的权重,觉得模型反正能猜。但实测下来,工具描述对模型选型准确率的影响,经常比参数 Schema 还大。同一个工具,“获取指定订单的详细信息”和“按订单 ID 查询订单详情,订单 ID 是从用户对话里抽取出来的 8 位数字”,后者的调用成功率会高出非常多。描述里最好把适用场景、常见参数来源写清楚,甚至可以写一个典型例子。
工具执行的封装也要遵循统一协议,每个工具本质上是一个接收结构化参数、返回结构化结果的函数:
TOOL_REGISTRY = {} def register_tool(name: str, description: str, parameters: dict): def decorator(func): TOOL_REGISTRY[name] = { "name": name, "description": description, "parameters": parameters, "func": func, } return func return decorator @register_tool( name="query_order_status", description="根据订单号查询物流状态,订单号形如 ORD20250101", parameters={ "type": "object", "properties": { "order_id": {"type": "string", "description": "8位订单号"} }, "required": ["order_id"] } ) def query_order_status(order_id: str): return {"status": "delivered", "detail": "..."}在这个基础上,还要加参数校验、错误捕获和结果标准化。工具执行过程中抛出的异常不应该直接阻断整个 Agent 循环,而是要转成一段“观察文本”回填给模型,让模型有机会纠错或换一种方式继续。比如工具因为权限不足失败时,返回值应该包含足够的错误上下文,让模型知道是权限问题而不是订单不存在。这听起来是小事,但实际效果差异非常大。
2.3 最小循环的实现:把 ReAct 思想落到代码里
ReAct 模式的核心是思考、动作、观察的交替循环。这个思想几乎所有 Agent 系统都在用,区别只是落地方式不同。一个最简版本的 Agent 循环,大概长这样:
messages = [{"role": "system", "content": SYSTEM_PROMPT}] messages.append({"role": "user", "content": user_request}) for step in range(MAX_STEPS): response = gateway.run(messages) if response.has_tool_calls(): messages.append(response.as_assistant_message()) for call in response.tool_calls: result = execute_tool(call) messages.append({"role": "tool", "tool_call_id": call.id, "content": result}) else: return response.text return "REACHED_MAX_STEPS"看到这你会发现,这个“最小循环”总共不到二十行代码。但就是这二十行代码,埋着无数个架构决策点:最大步数设多少?到达上限后返回什么?工具执行结果要不要截断?历史消息会不会撑爆上下文?模型连续多次请求调用同一个工具导致死循环怎么办?这些问题看着很小,但如果不在一开始想清楚,后面被线上问题反复折腾的就是你。
我自己实践下来的建议是:在这个阶段用纯循环就够了,但要从第一天就把step、final_response、tool_call_history这些字段固化下来,形成统一的消息对象。宁可你后面重写整个调度器,也不要前期把所有位置都用裸 dict 糊一层。因为这些字段在后续演进成工作流或图状态机时,都是需要被显式管理的核心状态。
3. 完整 Harness Runtime 的核心支柱:记忆、调度与感知
3.1 状态管理:从 Dict 到可追溯的状态容器
当你的 Agent 不只是一个玩具循环,而要承载真实业务时,最大的挑战往往不是模型不够聪明,而是系统状态混乱。最小循环阶段的局部变量、内存字典,很快就会撑不住。状态不但要在单次执行里传递,还要在多轮对话、跨会话恢复、人工介入审批等场景下保持完整和一致。
我建议把所有状态收敛到一个专门的AgentState容器里,它至少要包含这些分区:对话消息区、工具调用记录区、业务自定义字段区、系统元信息区(如当前步数、当前节点 ID、优先级、超时标记)。每个分区都有明确的读写接口,不允许业务代码直接乱改内部字段。
class AgentState: def __init__(self, session_id: str): self.session_id = session_id self.messages: list[dict] = [] self.tool_calls: list[ToolCallRecord] = [] self.custom_fields: dict = {} self.meta = {"step": 0, "current_node": "start", "status": "pending"} def append_message(self, msg: dict): self.messages.append(msg) def increment_step(self): self.meta["step"] += 1这里还有一条非常重要的心得:状态序列化格式要稳定。你可能会觉得状态就是存内存的结构体,随便设计就行。但当你开始用 Redis 做会话级缓存、用数据库做持久化、用消息队列做异步恢复时,状态结构的兼容性直接决定了这些迁移的难度。我见过一个团队早期把custom_fields设计成任意嵌套 dict,结果后面要按特定字段索引和检索时,才发现无法高效查询,只能做全量扫描。所以状态设计要预留可查询性,即使一开始只存内存,也尽量把字段拍平、命名规范、类型固定。
3.2 记忆管理:上下文压缩、滚动窗口与外部记忆库
LLM 调用的功耗表现与上下文的规模强相关,且上下文窗口永远不够用,这是 Agent 架构绕不开的第二道坎。很多初学者以为记忆就是把历史消息全怼进去,但一旦 Agent 的步骤变多,工具返回变长,消息列表就会迅速膨胀。
我目前在实际项目中验证下来比较稳的一套组合拳,是“窗口截断 + 关键信息摘要 + 业务状态外置”。窗口截断很好理解,只保留最近 N 轮对话,超出部分裁剪。关键信息摘要则是每次到达某个节点时,把之前的对话内容做一次压缩改写,提炼目标、已确认事实和待办事项,以摘要消息的形式插入上下文。业务状态外置则是把订单号、用户 ID、查询条件这些关键业务字段存到结构化状态里,模型需要时通过工具去读取,而不是靠上下文携带。
def compress_messages(messages: list[dict], max_chars: int = 8000): summarized = summarize_early_exchanges(messages[:-RECENT_KEEP]) recent = messages[-RECENT_KEEP:] return [{"role": "system", "content": "【历史摘要】" + summarized}] + recent这套方案有个很重要的好处:摘要本身可以作为可观测审计的依据。用户在排查 Agent 为什么做出错误决策时,看看摘要有没有遗漏关键信息,往往比翻完整条聊天记录更快。很多 Agent 上线后最棘手的问题,不是模型答错,而是用户反馈“我早就说过的条件你忘了”,这时候把摘要拉出来,责任归属一眼就清楚。
3.3 调度引擎:把“流程”和“能力”分离
随着业务复杂度上升,你一定会走到这一步:不再希望模型从零规划所有步骤,而是希望系统能用预设流程引导模型走完主线。这就是调度引擎的用武之地。调度引擎的核心价值,是把“业务流程”从“模型调用”中抽离出来,让流程可编排、可版本化、可灰度。
我通常把调度分为硬编排和软编排两类。硬编排指的是严格的 DAG 工作流——节点顺序、条件分支、并行分支都是预先定义好的,模型在每个节点只负责局部决策,比如抽取参数、生成回复文本、决定跳转到哪个分支。软编排则是给模型一个目标和一个工具集,让模型在约束范围内自主规划路径,系统只负责边界守护、资源限制和异常熔断。
这两者的边界不是我拍脑袋定的,而是根据任务的可预测性来定。凡是主流程清晰、分支有限的场景,默认走硬编排;凡是开放探索、信息搜集、方案规划类场景,默认走软编排。实际项目里大多数业务场景是两者的混合形态——比如客服系统的主流程“识别意图 → 查询订单 → 确认方案 → 执行变更”,每个环节内部又允许模型在多个工具里自主选择。
3.4 可观测性:没有追踪,架构再好看也白搭
前面聊了不少架构理念,但我想强调的是:在 Agent 系统里,可观测性不是上线后补的,而是架构设计时内建的。Agent 的决策链路天然具有非线性特征,同一个问题,模型可能走三条完全不同的工具调用路径。如果不从第一行代码开始记录决策轨迹,出了问题根本无法复现,更别提评估和优化。
我常用的做法是给整个执行链路加一个贯穿的 trace ID,从用户提问开始,每一步模型调用、工具选择、工具参数、工具结果、上下文截断动作、摘要动作全部落日志。日志结构最好是事件流式而不是单一 JSON 对象,因为一个 Agent 执行可能有 20 个步骤,每个步骤本身就是一组嵌套事件。
{ "trace_id": "abc123", "session_id": "sess_01", "step": 3, "node": "query_order", "event_type": "tool_call", "tool_name": "query_order_status", "input": {"order_id": "ORD20250101"}, "output": {"status": "delivered"}, "latency_ms": 320, "token_cost": 512, "ts": "2025-01-01T10:00:00Z" }针对线上 Agent 行为,我建议至少监控三个核心指标:任务完成率、平均步数、工具调用失败率。完成率好理解,平均步数则很能反映规划是否高效——正常任务 3 步能搞定,如果经常跑到 10 步,说明工具契约不清或提示词有歧义;工具调用失败率如果偏高,优先排查工具描述与参数 Schema,而不是怪模型不聪明。
4. 主流方案横评与演进路径:从循环到图,再到多 Agent 协作
4.1 原生循环(ReAct 风格)的适用边界
这种方式实现简单、调试直观、完全由模型主导流程,适用于单轮任务、工具数量少(比如五六个以内)、流程不可预测的探索性场景。交付速度快,调 prompt 也直接,因为整个决策过程就在一段循环里,没有多余抽象。
但它的问题也突出:难以处理复杂分支、难以插入人工审批节点、难以精确控制步骤、上下文膨胀快。一旦任务需要超过十几个工具,或者业务流程里需要加入强约束的节点(比如“必须人工审核后才能继续”),纯 ReAct 方案会变得脆弱。模型的每一步决策都要靠提示词兜底,而提示词对长流程的控制力相当有限。
4.2 工作流编排(Plan-and-Execute)的优势
在原生循环之上加一层“先规划后执行”的双阶段结构,就是 Plan-and-Execute。这一方案先用模型或固定规则生成一个任务计划(计划可以是结构化步骤列表),再按计划逐步执行,执行完一个节点后回到 Planner 看是否需要调整计划。
它的核心收益是“计划可展示、可干预”。尤其适合任务需要用户确认的场景——系统先把计划列出来,用户确认某几步后再执行,能明显降低不可控感。工程实现上也比单纯的循环多不了太多东西:一个 Planner 模块、一个 Executor 模块,外加一个计划存储区。
但 Plan-and-Execute 也有坑:如果计划在早期就制定错误,后续执行阶段很难自我纠错,往往要重新回到 Planner 全量重规划,这会带来额外的延迟和 token 开销。另外,计划列表如果本身很长,每一步执行时都要把完整计划塞进上下文,进一步加剧上下文膨胀。所以我通常建议计划尽量只保留“剩余步骤”,而不是全量携带。
4.3 图状态机方案:当 Agent 复杂到需要“状态转移图”
当任务的节点数量、分支条件、异常处理多到一张流程图画出来更清晰时,就该上真正的图状态机方案了。这里的图不是 DAG 工作流那种静态图,而是一张可以在运行时动态变化的状态机图:节点代表动作或决策点,边代表状态转移条件,全局状态决定当前处于哪个节点。
图方案最大的优势,是“每个节点的上下文边界清晰”。模型在每个节点只需要关注该节点关心的状态切片,不需要携带全量历史。其次,图方案天然支持人机混合流程——某个节点是模型决策,下一个节点是人工审批,再下一个又回到模型执行。这种拓扑结构在用循环实现时非常别扭,但图实现就是加一个节点和一条边的事。
实现图状态机时,有几个容易踩坑的地方。第一,节点类型不要只做一种“处理节点”,要区分动作节点、条件判断节点、并行分支节点和人工介入节点,否则后期图会越画越乱。第二,全局状态的读写要有一套既定的时序约定,比如节点开始前只能读、不能改全局态,节点结束时统一提交状态变更,避免多个节点并行时出现脏写。第三,图的执行需要设置全局超时和节点级超时,防止某个节点卡死导致整个 Agent 任务悬挂。
4.4 多 Agent 协作:最后才需要考虑的复杂度
多 Agent 系统这几年讨论度很高,但我对它的态度一直比较保守。只有当任务边界足够清晰、Agent 间协作频率足够低、单 Agent 无法在合理的上下文和工具数内完成任务时,我才会建议拆多 Agent。
多 Agent 架构至少有两种不同形态。一种是 Supervisor 模式,由一个主 Agent 负责任务拆解和分配,子 Agent 各自执行子任务并汇报结果;另一种是 Peer 协作模式,多个 Agent 平级,通过共享黑板状态或消息队列交换信息。前者的实现和调试明显更直观,我推荐绝大多数团队从 Supervisor 模式开始,而不是一上来就做完全去中心化的协作。
就算拆了多 Agent,底层每个 Agent 内部依然是个循环或图,这套架构没有消灭复杂度,只是把复杂度转移到了信任边界和通信协议上。你在评估要不要上多 Agent 时,先问自己一个很现实的问题:这么多 Agent 之间的状态同步和失败重试逻辑,我们团队真的愿意维护吗?如果是,那再往下走不迟。
4.5 几个方案的选型对照表
为了方便你在开会评审时直接抛出一页判断逻辑,我把几个方案的适用情况做了个对照:
| 方案 | 实现复杂度 | 流程可控性 | 灵活度 | 适用场景 | 主要成本 |
|---|---|---|---|---|---|
| 原生循环 ReAct | 低 | 低 | 高 | 工具少、流程开放 | 上下文膨胀、决策不稳定 |
| Plan-and-Execute | 中 | 中 | 中 | 任务需展示计划、可人工确认 | 计划错误难纠偏 |
| 图状态机 | 高 | 高 | 中 | 流程复杂、人机混合、分支多 | 状态设计与流转调试成本 |
| 多 Agent Supervisor | 很高 | 中 | 高 | 边界清晰的多任务并行 | 通信与状态一致性 |
选型时可以参考一个简单的判断顺序:先问流程是不是完全开放、不可预测?是的话走循环或软编排;再问流程是否有人工介入点或严格分支?是的话走图状态机;最后问任务是否必须拆给多个专业 Agent?是的话再上多 Agent。反过来,如果一开始就是明确的主流程加少量分支,直接图状态机起步反而省事。
5. 架构演进的落地路线与关键避坑经验
5.1 一个务实的演进路径:先圈定边界,再谈合理设计
我不建议团队从第一天就追求一步到位的“终极架构 Agent 框架”。更务实的路线是:用最小循环快速验证模型效果和业务价值,同时预留清晰的演进接口。所谓预留,核心指的是状态结构、消息格式和工具协议的质量。这三点做扎实了,后面哪怕把整个执行内核从循环换成图状态机,上层业务代码的变动也会非常小。
我见过一个反例是本末倒置:项目启动第一天就把图状态机框架、多 Agent 通信协议、全套可观测平台全部搭好,结果业务价值还没验证清楚,团队已经消耗了大量精力在调试框架本身的 bug 上。Agent 系统的业务不确定性很高,先小步快跑验证,等确定性增加再逐步补架构,这个节奏通常更合理。
5.2 常见问题速查与排查心得
我把自己实践中最常踩的坑,整理成了一份备查清单:
| 现象 | 根因 | 解决思路 |
|---|---|---|
| 模型反复调用同一失败工具 | 工具描述不清或错误信息未回填 | 强化工具描述,返回结构化错误码 |
| 上下文很快被撑爆 | 未做窗口截断、工具结果过长 | 建立压缩机制,工具结果字段抽摘要 |
| Agent 决策路径抖动 | 系统提示词覆盖面不足 | 梳理场景案例,做成 few-shot 示例注入 |
| 并行分支状态错乱 | 全局状态读写时序未约定 | 节点结束统一提交,提交期间加锁 |
| 任务总是步数超限 | 流程缺少子目标拆解 | 检查是否需要 Graph 方案约束流程 |
| 线上行为无法复现 | 没有完整决策轨迹日志 | 全链路 trace 落地,事件流式记录 |
这里要特别展开一下工具结果回填的问题。很多团队只关注模型的“输入”和“输出”,忽略工具执行结果本身也是模型的“输入”。工具结果长度、格式、错误信息质量,直接决定模型下一步决策质量。我现在要求所有工具返回值必须显式包含“操作是否成功”的标识,而不只是返回一段自然语言文本。比如查询订单失败时,返回{"success": false, "error_code": "ORDER_NOT_FOUND", "message": "订单不存在"},模型看到success=false就知道该走纠错分支,而不是把“订单不存在”这段字面文本当作业务数据去理解。
5.3 关于评测:架构选型好不好,要拿评测结果说话
最后一个想聊的是评测。Agent 架构的选型,不能靠主观感受评判“看起来挺灵活”或者“这个方案更高级”,必须建立一套面向任务的评测集。评测集不要只放 happy path,必须包括异常分支、多轮切换、用户中途改口的场景。
评测指标上,除了模型单步回答正确率,更要关注端到端任务成功率、平均执行步数、工具调用有效率、上下文 token 消耗。比如同样一批任务,方案 A 的成功率比方案 B 高 5%,但平均 token 消耗多了 40%,这时候要结合业务场景判断哪个更能接受。对线上系统来说,token 成本往往是被忽视的架构选型隐性因素,有些方案看着效果好,但跑一个月成本翻倍,一样会被业务方否决。
我个人的体会是,Agent 架构没有银弹。一套架构在一个团队是神兵利器,换到另一个团队可能因为缺少运维能力、缺少上下文压缩经验,反而成为负担。真正重要的不是搭出多酷炫的图,而是你清楚知道每一层抽象解决了什么问题、带来了什么新的复杂度,并且在业务发展的每个阶段,团队都刚好养得起这套复杂度。如果你能同时维护好工具契约和决策轨迹这两条生命线,那不管底层方案怎么切换,整个系统都不会偏航。最后再分享一个小习惯:每次线上 Agent 出一次幺蛾子,别急着改提示词,先看一眼决策轨迹日志,绝大多数问题的根源都在日志里写着呢——只是你之前没设计好日志结构而已。