Agent工程化核心:预留好这些插槽,才能让AI应用可组装可扩展 最近在整理 Agent 工程化笔记的时候我越来越觉得一个比喻特别贴切把 Agent 当成一台可以自己组装的整机而 DeepAgents 这类设计要解决的核心不是把某个零件做得多漂亮而是告诉你应该在哪些位置预留好“插槽”。看过不少 Agent 项目之后最大的感慨是很多团队一开始就把模型接进去跑通了等到想换模型、加工具、接长期记忆的时候才发现整块代码已经长成一坨根本不敢动。这篇文章算是一个前置介绍我想先把一个基本问题聊透Agent 身上哪些位置应该预留插槽这些插槽通常长什么样以及为什么把这些“接口位”设计好比急着把功能写死更重要。适合正在做 Agent 工程化或者刚入门想理解“可扩展 Agent 架构”的人读。1. 为什么要聊“插槽”先把 Agent 当成一台可组装的整机1.1 散件思路Agent 不是一块铁板很多人对 Agent 的理解就是一个“大模型 提示词 几个工具函数”。这种理解没有错但太粗了。真正上线运行的 Agent内部至少包含模型调用、提示词管理、工具定义与执行、记忆读写、任务规划、结果输出、异常处理、权限控制这些环节。如果这些环节全部写在一个类里或者用一个又一个硬编码分支串起来那它本质上还是一块铁板不是一台可以升级的整机。我自己习惯用装电脑来类比。CPU、内存、硬盘、显卡都是散件每一样都可以单独升级但前提是主板上留好了对应的接口。Agent 的主板就是核心调度逻辑而 Agent 身上的插槽就是那些跨模块、跨组件之间稳定不变的调用协议。只要协议稳住了内存坏了换内存显卡不够用换显卡不需要把整个主机拆了重装。Agent 也是一样模型想换就换工具想加就加记忆想从内存版换成向量库版都不应该影响上层业务逻辑。这里的关键是“拆分”和“边界”。拆得不够插槽没有存在意义拆得太碎插槽变成摆设。实践里我会先看一个 Agent 有哪些“可能被替换的散件”。模型会被换工具会越加越多记忆会从简单列表变成持久化存储规划策略会从直接回答变成多步推理。每个“可能被替换”的边界就应该有一组稳定的接口也就是插槽。1.2 DeepAgents 到底在讲什么我不太愿意把 DeepAgents 当成某一个具体框架的名字来理解它更像一套“深度组装”的方法论先拆解 Agent 的职责边界再定义插槽最后把散件装起来。这里的“深”体现在两个地方。第一不是只做表面封装而是深入到每个 Agent 组件的生命周期连日志、异常、状态传递都要有统一的出口。第二不是只在一台 Agent 内部做插槽而是把多 Agent 协作、外部系统接入也当成一种“插槽”来设计让整机可以继续接入更大的系统。这篇文章之所以叫“前置介绍”是因为很多讨论到最后都会落到“怎么设计一个叫 DeepAgents 的东西”上。但在写具体方案之前最好先统一一下对插槽的认知。否则很容易出现一种情况代码里到处写着接口但每个接口都没想清楚是给谁看的、为什么存在、替换的时候到底替换什么。前置介绍就是把这几个问题先交代明白。一个额外的提醒是插槽不是越多越好。我看到过一些 Agent 项目抽象了十几个接口结果每个接口只有一种实现用起来还要绕好几层。插槽的价值在于“预留替换空间”不在接口数量。真正值得预留的是那些你已经能预见到会变化的边界而不是每个方法都包一层。散件太多整机反而跑不动。2. Agent 身上的常见插槽从输入到输出哪些位置必须预留2.1 模型插槽最容易被想到的替换点几乎每个 Agent 都会用到模型所以排在第一位的肯定是模型插槽。这个插槽要解决的不是“今天调哪个模型”而是“未来换模型的时候上层代码到底要不要改”。如果你在业务代码里直接写了某个模型服务商的 SDK 调用那以后想切到另一个服务商几乎等于重写所有调用点。模型插槽的作用就是把“模型长什么样”这件事封装成一个稳定协议。模型插槽至少要包含两件事一个是输入格式一个是输出格式。输入通常是 messages 列表加上可选的工具定义以及 temperature、max_tokens 这类采样参数。输出不能直接把原始响应丢给上层最好统一成 content、tool_calls、usage 这样的结构。这样上层只关心“模型给了我什么内容、想调用什么工具”而不关心这个内容到底来自哪家服务。我在实际项目里会先定义一个最小的模型接口类似这样from typing import Any, Protocol class ModelSlot(Protocol): 模型插槽所有模型实现都要遵守这个协议。 def chat( self, messages: list[dict], tools: list[dict] | None None, **kwargs: Any, ) - Any: 返回统一格式的响应包含 content 和 tool_calls。 ...用 Protocol 而不是抽象基类是为了让“符合这个接口”这件事不依赖继承关系。任何类只要实现了 chat 方法且返回结构一致就可以塞进这个插槽。测试的时候甚至可以写一个 FakeModel固定返回“我不确定”用来单独调试上层逻辑。模型插槽最容易犯的错是把采样参数也当成插槽协议的一部分。比如 A 服务商支持 top_pB 服务商不支持你如果把它写死在协议里实现方就会很痛苦。更好的做法是协议只定义最稳定的输入输出其他参数放到 kwargs 里由具体实现决定怎么映射。这样换模型的时候除了适配器内部上层一行都不用改。2.2 工具与知识插槽能力扩展的入口工具是 Agent 真正的“手和脚”。工具插槽要解决的问题不是怎么写一个函数而是怎么让 Agent 框架统一发现、描述、调用和执行工具。一个标准的工具插槽至少要有注册和调用两个动作。注册时每个工具都要提供名称、描述、参数 JSON Schema调用时传入工具名和解析好的参数对象返回标准结果。统一参数 Schema 特别重要。大模型在规划时是根据工具描述和参数结构来决定“要不要调用、传什么参数”的。如果每个工具的参数说明风格都不一样模型很容易选错工具或者生成非法参数。所以工具插槽里我会强制要求每个工具都声明 JSON Schema并在注册的时候做一次格式校验。描述字段写清楚“什么时候用这个工具、不用会怎样”效果往往比堆一堆关键词好得多。知识库这边通常会有检索器和重排器两个插槽。检索器的接口可以设计成这个样子class RetrieverSlot(Protocol): def retrieve(self, query: str, top_k: int 5) - list[dict]: 返回文档列表每项至少包含 content 和 score。 ...重排器不是所有场景都需要但预留一个这样的插槽能让 Agent 从“只依赖向量相似度”平滑过渡到“向量召回 精排”。我自己就把知识库后端换过三次第一次是本地 JSON 文件第二次是向量数据库第三次加了混合检索。因为接口一直没变替换成本被控制得很低。工具插槽这里有个坑有些框架喜欢把工具定义直接写进系统提示词里然后靠模型自己输出一段 JSON 来调用。这本身没问题但如果你把工具调用逻辑做成私有方法不给外部留注册口后续每加一个工具就要改核心代码Agent 的扩展性就消失了。正确的做法是维护一个工具注册表由插槽统一管理业务代码只负责往里面塞工具。2.3 记忆与状态插槽让 Agent 从“失忆”到“有记忆”一个对话式的 Agent如果每次请求都从零开始那它跟一个普通聊天接口没有区别。记忆插槽就是为了让 Agent 记住上下文、用户偏好和任务进度。记忆至少分成两层短期记忆和长期记忆。短期记忆可以简单到一个会话窗口里的最近 N 条消息长期记忆则可能落到向量数据库、关系型数据库或文件系统里。我习惯把记忆插槽定义为这样一组方法class MemorySlot(Protocol): def save(self, session_id: str, message: dict) - None: ... def load(self, session_id: str, limit: int 20) - list[dict]: ... def clear(self, session_id: str) - None: ...这里 limit 默认 20是我实际调过之后觉得比较合理的窗口大小。太大会超出模型上下文窗口太小又会丢失前文信息。需要注意记忆插槽不能只存用户消息和助手消息还要把工具调用记录、状态变更这种“过程数据”一起存进去否则 Agent 重启之后根本不知道之前执行到了哪一步。状态插槽跟记忆插槽容易混淆。我更倾向于把状态理解成“机器的寄存器”存的是任务 ID、当前步骤、待确认标志、重试次数这类短期运行数据。状态插槽的接口可以是一个简单的 Key-Value 读写也可以用类似get_state()、update_state()这样的方法。它在多轮任务、人工审批、断点续跑这些场景里非常关键。实际设计时我会把“记忆实现”和“记忆使用方式”分开。记忆插槽只暴露读写接口至于底层是内存、Redis 还是向量库完全封装在实现类内部。这样 Agent 核心逻辑不会感知到存储细节。一个常见的错误是在协议里混入了太多实现概念比如把“向量化”写在接口上导致所有实现都必须处理向量哪怕你只是临时用一个内存字典。2.4 规划与执行插槽策略可以替换而不是写死在代码里不同任务需要不同的规划策略。简单问答直接让模型回答就行复杂任务需要多步推理、子任务分解、反思验证。如果把这些策略写死在 Agent 主流程里想切换就非常麻烦。规划插槽解决的就是这个问题把“怎么决定下一步做什么”单独抽象出来。规划插槽的输入通常是用户任务、上下文状态、可用工具列表输出是一串步骤。执行插槽则负责执行单个步骤并返回执行结果。用代码表达大概是class PlannerSlot(Protocol): def plan(self, task: str, context: dict, available_tools: list[str]) - list[dict]: 返回步骤列表每个步骤包含动作和参数。 ... class ExecutorSlot(Protocol): def execute(self, step: dict, context: dict) - dict: 执行一个步骤返回结果和状态。 ...规划插槽的好处是你可以在运行时替换策略。比如刚开始用最简单的“直接回复”策略后来希望 Agent 自动决定是否调用工具就可以换成一个带反思的规划器。上层代码不需要知道策略怎么变的只要拿到步骤并交给执行器就行。我还习惯预留一个“验证器插槽”。规划完之后用验证器检查一下步骤是否合理执行完成后再检查结果是否符合预期。这个插槽对复杂任务特别有用因为模型规划经常出现“看起来合理实际跑不通”的情况。验证器可以把问题提前拦下来而不是等最后结果错了再回溯。这个部分最容易犯的过度设计是把规划器搞得过于复杂一上来就支持子任务、并行、依赖图。我的建议是先从一个简单的顺序规划器开始插槽协议不变后面需要并行再在实现里扩展。协议保持简单实现才能灵活。2.5 生命周期与交互插槽容易被忽略的隐形插槽除了模型、工具、记忆、规划这些“组件插槽”Agent 运行的过程中还需要一组横切面的插槽它们不承担具体业务但负责把整个运行过程串起来。最常见的就是生命周期钩子on_message、on_tool_call、on_error、on_done。这些钩子可以用来打日志、做审计、上报指标、限流、计费也可以在特定节点插入人工审批。生命周期插槽的设计一般不是让每个业务模块继承一个基类而是提供事件注册机制。比如允许外部传入一个回调函数当工具即将执行时触发。这样日志系统、监控系统、审批系统都能独立接入互不干扰。交互插槽也很重要。一个 Agent 不可能永远全自动运行涉及敏感操作时需要人工确认涉及权限不足时需要提示用户授权。我会把这类交互抽象成HumanInTheLoopSlot包含request_confirm(step) - bool这样的方法。这样 Agent 主流程里只需要调用一个“询问”动作具体是发站内信、推消息还是邮件完全由实现决定。组件插槽和钩子插槽有一个本质区别组件插槽一般是一对一替换钩子插槽是一对多监听。用表格看会更清楚类型典型位置核心问题常见实现组件插槽模型、存储、检索、规划替换某个部件时不影响整体协议类、适配器钩子插槽工具调用前后、异常时、结束时让外部系统可以旁路监听和介入回调注册、事件总线两者都要预留但不要混在一起。组件插槽应该尽量干净钩子插槽则要允许叠加。如果在组件接口里塞了一堆钩子参数实现方就会很难受。3. 实操一个最小化的“散件到整机”组装记录3.1 先定义插槽协议而不是先写实现前面把插槽类型讲了一堆现在落到实际。我的习惯是动手写第一个 Agent 功能之前先把协议文件建好。协议文件里只有接口和类型定义没有任何业务逻辑。这样后面不管是接真实模型还是写模拟实现都遵循同一组规则。我常常会写这样一个最小协议文件# slots.py from typing import Any, Protocol class ModelSlot(Protocol): def chat(self, messages: list[dict], tools: list[dict] | None None, **kwargs: Any) - Any: ... class MemorySlot(Protocol): def save(self, session_id: str, message: dict) - None: ... def load(self, session_id: str, limit: int 20) - list[dict]: ... def clear(self, session_id: str) - None: ... class ToolSlot(Protocol): def register(self, name: str, description: str, schema: dict, handler: Any) - None: ... def schemas(self) - list[dict]: ... def execute(self, name: str, arguments: dict) - Any: ... class PlannerSlot(Protocol): def plan(self, task: str, context: dict, available_tools: list[str]) - list[dict]: ...先用 Protocol 而不是抽象基类的原因主要是为了保持低侵入。某个记忆存储类可能是某个云服务 SDK 提供的它不一定愿意继承你的抽象基类但只要它实现了 save、load、clear 方法就可以直接当作 MemorySlot 用不需要额外适配。这种 Duck Typing 风格在 Python 项目里非常顺手也很适合搭 Agent 这种组装型系统。不要急着给协议加很多方法。每个协议先保底后续发现不够用再加新方法。但是新增方法会影响所有实现类所以版本管理要做好。我一般会在协议类名上留版本号比如ModelSlotV1如果要改协议就新建ModelSlotV2并提供一个兼容适配器。3.2 写两个简单的散件实现协议定义完就可以写实现了。为了演示我先写一个最简单的模拟模型。它不真正调用大模型只把最后一条用户消息原样返回用来验证 Agent 装配链路是否通畅class EchoModel: def chat(self, messages, toolsNone, **kwargs): last_message messages[-1][content] return SimpleResponse(contentf模拟模型返回{last_message}) class SimpleResponse: def __init__(self, content, tool_callsNone): self.content content self.tool_calls tool_calls or []这个类没有继承任何东西但因为实现了 chat 方法所以可以被当作 ModelSlot 使用。接着写一个最简单的内存记忆实现class InMemoryMemory: def __init__(self): self.storage {} def save(self, session_id, message): self.storage.setdefault(session_id, []).append(message) def load(self, session_id, limit20): return self.storage.get(session_id, [])[-limit:] def clear(self, session_id): self.storage.pop(session_id, None)再写一个只支持一个工具的最小工具槽比如一个加法计算器class SimpleToolSlot: def __init__(self): self._tools {} def register(self, name, description, schema, handler): self._tools[name] {description: description, schema: schema, handler: handler} def schemas(self): return [ {name: name, description: info[description], parameters: info[schema]} for name, info in self._tools.items() ] def execute(self, name, arguments): tool self._tools.get(name) if not tool: raise KeyError(funknown tool: {name}) return tool[handler](**arguments)注册一个加法工具def add(a: int, b: int) - int: return a b tool_slot SimpleToolSlot() tool_slot.register( add, 计算两个整数的和适用于求和场景。, {type: object, properties: {a: {type: integer}, b: {type: integer}}, required: [a, b]}, add, )这里之所以把工具描述写清楚是因为模型要靠它判断什么时候调用。描述越含混模型越容易在不需要加法的时候乱调用。这种细节通常只在踩过坑之后才注意得到。3.3 组装整机与启动自检协议和散件都有了现在把它们装成一个 Agent。我会写一个最小的组装类class AssembledAgent: def __init__(self, model, memory, tools, planner): self.model model self.memory memory self.tools tools self.planner planner def run(self, user_input: str): history self.memory.load(default) messages history [{role: user, content: user_input}] plan self.planner.plan(user_input, {}, [t[name] for t in self.tools.schemas()]) if plan and plan[0].get(action) call_tool: step plan[0] result self.tools.execute(step[tool_name], step[arguments]) messages.append({role: function, name: step[tool_name], content: str(result)}) response self.model.chat(messages, toolsself.tools.schemas()) self.memory.save(default, {role: user, content: user_input}) self.memory.save(default, {role: assistant, content: response.content}) return response.content这个类足够小但它已经把模型、记忆、工具、规划四个插槽接起来了。这里的规划器我写一个最简单的直接规划器class DirectPlanner: def plan(self, task, context, available_tools): if add in available_tools and 加起来 in task: return [{action: call_tool, tool_name: add, arguments: {a: 1, b: 2}}] return []实际组装就是传参agent AssembledAgent( modelEchoModel(), memoryInMemoryMemory(), toolstool_slot, plannerDirectPlanner(), ) print(agent.run(把 1 和 2 加起来))启动自检是必不可少的一步。我会给每个插槽加一个ping()或者最小调用测试。模型插槽可以调一次空对话记忆插槽可以写读一次临时 key工具插槽则检查注册表是否为空。自检不通过服务就不要对外暴露否则线上会出现“看起来活着实际一调用就挂”的状态。自检还有一个作用是在启动时把“默认实现”和“用户传入实现”区分清楚。如果某个插槽没有显式传入启动日志要打警告。空插槽运行起来不是不行但很容易让人误以为系统完整实际功能缺失。所以组装类里我通常会记录一张装配清单标明每个插槽当前绑定的实现类名。3.4 热替换和运行日志链路插槽设计最大的红利之一就是运行时的热替换能力。比如 Agent 跑着跑着你发现内置内存存储已经扛不住流量了想换成持久化存储。只要新实现满足 MemorySlot 协议就可以直接替换agent.memory SQLiteMemory(agent_state.db)此时运行中的 Agent 不需要改任何逻辑。但热替换有一个坑已经加载到内存中的会话数据不会自动迁移。我通常会在替换前先调用旧实例的导出方法再把数据灌进新实例。如果没有数据迁移需求也要确认旧的连接能正常关闭避免泄漏。日志链路也很关键。插槽只是一个一个的接口真正让问题可见的是贯穿所有插槽的追踪 ID。我会用 contextvars 给每个请求生成一个 run_id然后在每个插槽入口和出口打日志import contextvars run_id_var contextvars.ContextVar(run_id, default) def trace(slot, event, **fields): print(f[{run_id_var.get()}] slot{slot} event{event} {fields})日志不用很复杂但一定要在关键节点留下痕迹模型调用前、工具执行前、记忆写入后、规划返回后。这样出问题时可以顺着 run_id 把整条链路串起来而不是在几十个模块之间反复断点调试。我见过太多 Agent 项目运行时黑盒一样出了问题只能靠猜。4. 常见问题与排查技巧实录4.1 插槽设计最容易踩的五个坑先说最常见的五个设计坑都是我亲手踩过的。第一个坑是协议定义过早。项目一开始还没想清楚边界就先抽象了一堆接口结果需求一变接口方法名反复改所有实现类跟着遭殃。协议应该等到“确实有两个不同的实现需要被替换”时再去提炼不要为了抽象而抽象。第二个坑是默认实现与接口耦合。比如默认的 ToolSlot 里除了协议方法还额外暴露了一个load_builtin_tools()方法上层代码如果直接调用这个方法那自定义实现就没办法无缝替换。正确的做法是上层代码只依赖协议方法所有扩展能力都走注册机制。第三个坑是生命周期不统一。有些插槽打开文件句柄有些启动后台线程有些会建立数据库连接。如果插槽协议里没有统一的关闭入口组装类就只能手动判断类型去清理很容易漏。我会在插槽协议里加一个可选的close()方法并在 Agent 关闭时统一调用。第四个坑是不可观测。插槽内部没有日志没有耗时统计没有错误包装。一次链路失败你只能在最外层看到结果异常根本不知道是模型超时、工具抛错还是记忆写入失败。插槽既然是边界就一定要在边界处留下可观测性。第五个坑是吞异常。很多实现为了“不让整个流程挂掉”会在插槽内部 catch 所有异常然后返回一个空结果。这在某些场景下是合理的比如一次检索失败可以降级为不检索。但吞异常必须带日志和指标不能静默吞掉。否则系统会表现出一种“很稳定但不干活”的假象排查起来特别痛苦。4.2 排查流程插槽“没生效”到底怎么查插槽没生效是很常见的问题。你写了一个自定义 MemorySlot启动日志里也看到了但跑起来发现数据根本没存进去。这种问题十有八九不是实现代码有问题而是装配的时候接错了。我的排查顺序是固定的。第一检查装配现场确认 Agent 实例构造时传入的确实是你写的那个实现而不是默认值。第二看启动日志的装配清单确认当前绑定的实现类名符合预期。第三用最小假实现测链路比如 FakeModel 固定返回一段文本看 Agent 主流程能不能跑通把问题范围从“整机”缩小到“某个插槽”。第四如果只有特定实现出错直接单测那个实现输入最小用例逐步观察输出。有一个特别容易搞混的点是插槽被后续赋值覆盖。比如 Agent 初始化时传入了一个自定义记忆但某段代码在运行时又执行了agent.memory InMemoryMemory()那你前面传的自定义实现就被悄悄替换了。这种问题靠看启动日志是看不出来的最好在热替换的位置打印日志并且不允许在业务代码里随意覆盖插槽引用。我后来会加一层只读属性或统一通过 setter 方法替换避免这种隐式覆盖。还可以配一个简单的检查命令把每个插槽的类型、状态、最近一次调用结果都打印出来。我在本地调试时经常用python -m agent_cli inspect --slots它会输出模型、记忆、工具、规划器各自的实现类以及自检结果。排查问题的速度会快很多。4.3 兼容性升级插槽协议不是一成不变的插槽协议稳定但不可能永远不变。需求变了模型接口要支持多模态了记忆要加批量导出了工具要支持异步了。协议升级是必然的。问题是怎么升级才能不破坏已有实现。我一般会把协议按语义化版本管理。小版本升级比如增加一个可选方法必须给出默认实现不能让所有实现类立刻报错。大版本升级比如改变了方法签名就建新的协议类并保留旧协议一个弃用周期。在弃用周期里通过适配器把旧实现包装成新实现让调用方平滑迁移。适配器很好用。比如旧模型插槽只支持文本新模型插槽支持多模态但现有服务商 SDK 只提供文本。我可以写一个适配器让旧实现伪装成新实现多模态部分返回“不支持”。这样系统整体可以先行升级等到服务商支持多模态后再补一个真正的实现。兼容层不要拖太久。每多一个弃用协议代码库的复杂度就上升一层。我的习惯是每个大版本只留一个弃用协议到下个大版本直接删掉。插槽是给扩展留门不是给历史债留门。5. 一些额外的心得从单机插槽到更远的组装插槽这套思路并不仅限于单台 Agent 内部。如果你把多个 Agent 放在一起每个 Agent 对外暴露的 Broker 接口、任务订阅接口、结果发布接口本质上也是插槽。只是这层插槽的“协议”从 Python Protocol 变成了网络协议或消息格式。我见过一个比较舒服的多 Agent 设计就是让每个子 Agent 都提供标准化的能力描述编排器通过能力描述决定把任务派给谁。这种设计比硬编码路由要灵活得多。更进一步插槽可以演变成插件市场。把每个工具、记忆后端、规划策略打包成标准插件统一声明清单和依赖然后在系统运行时动态加载。这样整个 Agent 的能力就能像现在手机装应用一样按需安装、按需升级。这个方向很有意思但从工程角度来说契约稳定性要求会特别高否则插件装多了接口版本冲突就会变成新的噩梦。我在实际项目里的体会是插槽设计最难的从来不是技术而是克制。知道在哪里留口子也知道在哪里不加多余的层。真正好的 DeepAgents 式设计不是把系统装饰成到处是插槽的“花架子”而是让每个替换点都清晰、稳定、可观测。少即是多够用就好后续需要再扩展这也是一种能力。