恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
从插件化到AI-Ready:智能体编排系统架构升级实战
首页
资讯中心
/
从插件化到AI-Ready:智能体编排系统架构升级实战
从插件化到AI-Ready:智能体编排系统架构升级实战
发布时间:2026/10/17 16:35:27
说实话我半年前被“插件化”这三个字坑得不轻。当时我负责一个智能体编排系统的架构升级系统本身已经做得挺“干净”了所有能力模块都实现了同一个插件接口注册表统一加载配置写好就能扩展新技能开闭原则贯彻得相当到位。结果等我把真正支持Function Calling的模型服务商接进来整个系统像多米诺骨牌一样塌了——不是崩溃那种塌而是逻辑上的塌模型根本不理解工具参数的含义上下文状态在好几个模块里各存一份流式生成和工具调用互相抢事件通道。折腾了两周我才想明白旧系统再怎么优化边界逻辑都是按照“人来驱动”设计的而AI场景的调用方是大模型它只能通过描述、Schema和上下文来理解你的系统这完全是另一套游戏规则。后面我把系统朝“AI-Ready”的方向重构了一版。我很清楚“AI-Ready”这种词很容易被当成蹭热度的标签但落到架构上它其实是一组非常具体的设计约束工具能不能被模型正确调用上下文能不能被完整追踪流式事件能不能在工具执行中无损透传模型提供方换了以后业务代码动不动。这篇文章就是这次升级的完整复盘重点是几层抽象的划分、工具协议的设计以及从插件化老代码迁过来的时候我踩过的坑。适用范围不挑技术栈只要你在做智能体系统、Agent编排或者准备把老系统开放给大模型当工具都能直接参考。1. 插件化架构为什么会在智能体场景失灵1.1 插件化解决过的老问题在传统后端系统里插件化确实是个好东西。它把“能力扩展”和“业务主流程”解耦开核心系统定义一个接口各种能力模块各自实现然后通过注册表把实现类找出来。哪怕现在很多人已经不用复杂的插件框架了本质思路仍然是统一接口、动态注册、按需调用。这套模式的好处是显而易见的新增能力不需要改主流程代码不同团队可以并行开发各自的能力模块能力之间也天然隔离。但这里藏着一个很少被明说的前提调用方是人或者是由人写死的业务逻辑。人也看接口文档也看注释哪怕某个插件的参数起得含糊人可以通过上下文脑补出来该怎么调用。就算调用链路上出了奇怪的问题人可以打开日志慢慢看。大模型不一样它只能依靠你塞进Prompt里的工具描述、参数Schema和示例来“理解”用什么工具、传什么参数。而且一旦描述与实现不一致它不会像人一样去翻源码确认只会顽固地按自己的理解方式调用然后告诉你执行失败。我这次改造前做过一次量化小统计当时系统里大概有40多个“能力插件”其中能给出完整JSON Schema参数描述的不到三个。剩下的全都是“接收一个dict参数内部自己去解析”这种设计。这种设计在人写的业务链路里不算大问题在模型驱动的场景里几乎等于没有接口。1.2 接入模型之后暴露出来的几个具体崩溃点这次迁移让我把问题看得特别清楚归纳成五个互不重叠的崩溃点第一是工具描述缺失。旧插件年代的接口只暴露方法签名run(self, params)这种接口太常见了。模型拿到这样的工具定义时根本分不清params[s]里面应该放字符串还是数组更别提知道这个方法执行完会返回什么。工具描述不完整模型就只能瞎猜猜错了又不会主动重试别的工具整轮对话直接僵住。第二是输入输出类型与模型不兼容。很多插件处理的是业务对象比如DataFrame、文件句柄、自定义实体类。在预设的调用链路上这些类型没问题但模型要的是结构化JSON它需要把工具结果再次塞回对话上下文。数据一旦无法序列化整条链路就断了。我见过最典型的情况是工具返回了一个游标对象程序在本地调试一切正常换成模型调用以后返回内容完全无法进入下一轮上下文日志里全是类型转换报错。第三是有状态单例污染上下文。插件系统为了省事经常把数据库连接、缓存对象直接挂在插件实例上。多个会话共享一个实例A用户的会话改掉了内部状态B用户的下一轮推理拿到的就是脏数据。这个问题在调试时极其隐蔽因为单看一次调用完全看不出问题只有把连续几轮推理的上下文对比起来才发现状态被跨会话污染了。第四是没有流式事件的出口。支持Function Calling的模型在生成过程中会连续吐出多个增量事件其中可能夹着工具调用指令。旧插件接口设计的是“一次调用、一次返回”完全没有把流式事件逐段透传的能力结果就是文本流全被堵住用户看到的回复像挤牙膏一样一字一字往外跳体验非常差。第五是工具集没有按场景裁剪的机制。传统插件系统注册多少工具都用得过来但模型不行每多一个工具就多占用上下文窗口工具本身还要消耗token。更麻烦的是某些模型对单次请求的工具数量有上限工具一多后注册的工具直接被截断模型根本看不到。这五个问题组合起来本质上是“模型驱动”的新调用方式对旧“人驱动”架构的全面冲击。传统插件化把“能力”封装得很好但没有考虑“能力如何被外部智能体理解”这件事。封装能力只是第一步表达能力才是AI场景的第二步。1.3 AI-Ready真正要求的边界条件我把这次改造的边界条件整理成一张对照表方便你判断自己的系统处在哪个阶段维度传统插件化范式AI-Ready范式调用发起方人的业务代码大模型能力说明接口文档、代码注释机器可读的JSON Schema上下文管理分散在各模块自行维护统一会话轨道、可追踪工具输出任意强类型对象可序列化、可回灌对话流式能力不需要增量事件必须透传工具集装配全量注册、手动加载按业务场景动态裁剪说白了AI-Ready不是“加一个AI接口”那么轻巧而是要把系统里每个对外能力都变成模型可以直接理解和消费的形态。判断标准只有一个让一个从未见过你代码的大模型光靠工具描述就能正确调用你的系统并且调用结果能顺利回到对话流程里继续推理。这是最朴素也最严格的验收条件。2. 整洁架构在智能体系统里的分层落地2.1 为什么传统的三层架构在Agent场景不够用在改造开始前我先跟团队把统一架构聊透原因很简单如果大家脑子里没有同一张分层图后面所有抽象设计都会变成各写各的。传统三层架构把Controller、Service、DAO分得很清楚但放到智能体系统里有一个致命问题Service层里往往同时混着模型调用逻辑、工具选择逻辑、状态管理逻辑。一旦换了模型服务商Service层要大改一旦新增一类工具Service层也要大改。整洁架构的核心思想是把依赖方向倒过来业务核心在最内层不依赖任何外部框架外层是适配器和基础设施反过来依赖内层定义的接口。放到Agent场景里这意味着模型服务商只是一个“适配器”它不是业务核心。你用的模型可能今天支持Function Calling明天换了别家模型业务层的工具编排逻辑不应该有任何变动。我这次改造最大的体会是在Agent系统里受保护的不是数据库表结构也不是某个工具算法而是“状态如何流转、上下文如何组装、工具调用如何被建模”这一组核心业务规则。只要这些规则不被模型SDK、消息队列、数据库访问这些细节绑架系统就还有救。2.2 具体分层职责划分我们用四层来切每层职责非常明确层级职责范围依赖方向AI场景下的关键点领域层ToolMeta、AgentContext、ToolResult、会话状态模型不依赖任何框架纯领域对象全部是数据类和纯函数应用层工具选择、上下文组装、Agent执行循环、流式事件分发只依赖领域层接口核心编排规则可完整单测适配层模型服务商适配、工具运行时包装、持久化存储适配实现应用层定义的接口换模型只动这一层基础设施层具体SDK调用、具体数据库连接、具体队列客户端实现适配层接口可替换、可隔离领域层看起来“什么都没有”但恰恰是它稳住了整个系统。ToolMeta描述一个工具长什么样AgentContext描述一场会话当前的状态ToolResult描述一次调用的结果。这些对象不引用任何第三方包测试起来成本极低。应用层是整个系统的“大脑”它不关心你用的是哪家模型服务商也不关心工具跑在什么环境里它只负责编排把上下文组装好、把候选工具选出来、把执行循环转起来、把流式事件分发到正确的通道。应用层与模型解耦的方式就是定义一套自己的接口让适配层去实现。2.3 依赖方向定不好后面全是循环依赖很多团队在落地整洁架构的时候最容易犯的错是把接口放错层。比如模型提供方的接口如果定义在基础设施层应用层要调用它就得反向依赖依赖方向就乱了。我们当时的约定是所有关键接口都定义在应用层基础设施层去实现这些接口。模型转账、工具执行、状态持久化全部遵循“应用层定义抽象基础设施层提供实现”这个总原则。循环依赖在编译期往往能躲过检测直到运行时才炸。我见过最典型的例子是领域层为了记录日志直接调用了基础设施层的Logger对象结果后续所有模块都要依赖基础设施层才能编译整个分层形同虚设。排查方法很简单画一张依赖图目标是从外向内单向流动任何一条从内指向外的箭头都是坏味道。3. 关键抽象的具体设计细节3.1 工具协议把“能力说明”和“执行逻辑”分开这是整个改造里最重要的一步。传统插件把“接口签名”当成工具的全部但AI场景里模型能感知的不是函数签名而是结构化的工具元数据。所以我们把工具抽象拆成两个部分一部分是模型可见的元数据另一部分是执行逻辑。# 领域层不依赖任何框架纯数据对象 from dataclasses import dataclass, field from typing import Any dataclass class ToolMeta: name: str description: str parameters: dict[str, Any] # JSON Schema 子集 category: str general schema_version: str 1.0 timeout_seconds: float 10.0 dataclass class ToolResult: ok: bool value: str # 必须可序列化、可回灌给模型 error: str latency_ms: int 0 class Tool: meta: ToolMeta async def run(self, ctx: AgentContext, **kwargs) - ToolResult: raise NotImplementedError这个设计的核心意图是把“工具长什么样”和“工具怎么执行”彻底拆开。ToolMeta会被适配层翻译成模型服务商能识别的Function Schema所以它必须完全用JSON可描述的类型。ToolResult.value强制要求字符串而不是任意对象因为模型读到的永远是文本形态的返回内容。我后来还加了一个schema_version字段很有用。当你修改了某个工具的参数定义但模型缓存里还留着旧描述这个版本号能帮助你定位问题。比如某个工具从两个参数改成三个参数之后所有老会话的缓存描述全都失效排查的时候一看版本号就能明白是怎么回事。提示工具描述千万别写得太“文学”。描述是给模型做工具选择用的不是给人做宣传的。按用户提供的SQL查询订单数据返回结构化结果比强大的企业级智能数据查询引擎支持大规模集群和复杂分析好用得多。后者会把模型带偏让它以为这工具能自动处理各种凌乱的查询语义。3.2 AgentContext贯穿全链路的“会话轨道”旧系统里上下文东一份西一份有的存在插件实例属性里有的存在请求对象里还有的直接挂在进程全局变量上。多轮对话一来状态根本对不上。改造之后我们强制要求所有状态都收敛到一个AgentContext里全链路只传这一个对象。dataclass class AgentContext: session_id: str messages: list[dict] # 对话消息存档 history: list[dict] # 可供工具查询的长期记忆 token_budget: int 0 # 本轮还能消耗多少token variables: dict[str, Any] field(default_factorydict) trace: list[dict] field(default_factorylist)AgentContext看起来是个普通数据类但在我们的系统里它承担了两件事一是隔离不同会话的状态二是给所有工具的调用过程提供一个统一的挂载点。比如某个工具要记录一次日志它不应该自己去找日志服务而是把事件写进ctx.trace某个工具要读取前面轮次的结果也是从这个上下文里取。这个设计的额外好处是方便导出和排查。每次执行完把ctx.trace序列化存下来就等于保存了一份完整的“决策过程回放”。我后来排查问题的时候靠这份回放省了很多力气。3.3 流式事件与工具调用的标准出口流式功能是改造里最容易被低估的部分。模型在生成过程中会不断吐事件其中可能混着工具调用的增量指令。如果每个工具都自己定义一套事件格式上层拼接起来会非常痛苦。我们定义了一个统一的事件协议from typing import AsyncIterator, Literal, Any dataclass class StreamEvent: event_type: Literal[token, tool_call_start, tool_call_delta, tool_result, done] seq: int # 事件序号保证顺序 payload: Any # 不同类型挂不同数据所有模型提供的输出不管底层是哪种流式协议适配层都会翻译成StreamEvent序列输出。工具执行器产生的回调也走同一个类型。上层界面只消费统一的事件流完全不需要关心某条事件是从文本生成的还是从工具执行返回的。这里有一个关键约定tool_call事件只负责“声明”不负责“执行”。执行器收到tool_call_start后异步执行工具然后把结果以tool_result事件推回同一通道。这样做的好处是流式会话的时序能被严格管理不会出现工具还在跑、文本已经乱跳的竞态问题。4. 从旧基建到AI-Ready的改造三部曲4.1 第一步让工具定义变成数据改造的第一件事不是写代码是把所有旧插件的“能力定义”从实现里剥离出来。旧代码通常长这样# 改造前的典型写法 class QueryPlugin(BasePlugin): def run(self, params): sql params[sql] return self.db.query(sql)这段代码的问题很明显它只实现了执行逻辑却完全没告诉调用方“这工具是干嘛的、参数是什么、结果长什么样”。我们把它改成class SQLQueryTool(Tool): meta ToolMeta( nameexecute_readonly_sql, description对内部业务数据库执行只读SQL查询最多返回20行结果, parameters{ type: object, properties: { sql: { type: string, description: 合法的SELECT查询语句不允许WITH写操作, } }, required: [sql], }, categorydata, ) async def run(self, ctx: AgentContext, sql: str) - ToolResult: rows await self._db.query_readonly(sql, limit20) return ToolResult(okTrue, valuestr(rows))这一步做的是把“能力说明”变成模型可以消费的数据。描述里明确写了“只读”“最多20行”参数约束里规定了SQL必须是字符串这些信息会直接进Prompt模型在调用时会主动遵守。实测下来把这类约束写进描述比在代码里偷偷拦截可靠得多模型在决策阶段就会主动避开危险操作。我把当时40多个插件的迁移就按这个格式平铺开一次性把旧接口全部替换成Tool协议。这个步骤不需要动任何执行逻辑纯粹是增加或改写元数据收益最大、风险最低适合作为整个改造的起点。4.2 第二步统一模型提供方适配层第二步是定义模型提供方的适配接口。不管底层用哪个模型服务商我们只在自己的适配层里跟它对话。定义如下from typing import Protocol class ModelProvider(Protocol): async def chat( self, ctx: AgentContext, tools: list[ToolMeta], streaming: bool False, ) - AsyncIterator[StreamEvent]: ... class ToolDispatcher(Protocol): async def invoke(self, name: str, ctx: AgentContext, arguments: dict) - ToolResult: ...chat的输入是AgentContext和工具元数据列表输出是统一的事件流。模型服务商返回什么格式的消息由适配层负责翻译成StreamEvent。这样业务层永远不会直接接触某一家模型的特殊消息结构。比如某家模型把工具调用叫function_call另一家叫tool_use适配层把它统一成tool_call_start事件业务层无感。ToolDispatcher是工具执行侧的统一入口。名字一般就叫dispatcher它内部维护一个从“工具名”到“Tool实例”的映射表。模型返回了nameexecute_readonly_sqldispatcher就找到对应的工具实例传入arguments执行。注意工具名尽量用稳定的英文标识不要夹杂中文解释。很多模型服务商对工具名长度和字符集有约束名字一乱套调用侧就会频繁报错。我们吃过一次亏某个工具名带了下划线前缀结果某家模型每次都在工具名后面加个空格适配层被迫做了一层通用的名称归一化才救回来。4.3 第三步让编排循环可重放、可观测核心执行循环长这样async def run_agent_loop(ctx: AgentContext, provider: ModelProvider, dispatcher: ToolDispatcher): while True: assistant_events provider.chat(ctx, toolsselect_candidate_tools(ctx), streamingTrue) collected [] async for event in assistant_events: if event.event_type token: yield event elif event.event_type in (tool_call_start, tool_call_delta): collected.append(event) if not collected: break for call_event in collected: tool_result await dispatcher.invoke(call_event.payload[name], ctx, call_event.payload[arguments]) ctx.messages.append(convert_tool_result_to_message(call_event, tool_result)) yield StreamEvent(tool_result, ...)这个循环把整个流程串起来了模型先看到历史和上下文吐出阿拉伯文本或工具调用指令如果有工具调用执行完再把结果追加进上下文继续下一轮。直到模型不再调用工具循环自然结束。这里有两个容易被忽略的点一是select_candidate_tools它在每一轮动态过滤候选工具而不是把全量工具都塞给模型这是控制token消耗的关键手段二是ctx.trace的写入每次工具调用、每次事件派发我都记录一笔这样整条执行链路随时可以回溯。尤其在调试多轮工具调用互相依赖的复杂场景时一份完整的trace比看十页日志更有用。5. 迁移过程中踩过的坑与排查技巧5.1 领域层引用了基础设施层的依赖这是我们踩过的第一个大坑。当时为了让领域层的某个工具能直接查配置我们顺手引入了一个配置中心客户端。结果后续所有依赖领域层的模块都被迫带上了这个客户端测试起内部逻辑时整个服务都要跟着启动。这完全违背了整洁架构的方向。排查方法很简单看领域层文件的import列表。如果里面有某个基础设施模块的类名那股坏味道就已经出现了。处理方法也直接把配置数据在进入领域层之前就解析成普通参数领域层只消费参数不自己去拉配置。5.2 工具描述写得太“人话”模型被带偏写第一个版本的时候我把某个工具的描述写成了你可以使用该工具查询用户维度的销售数据。注意销售数据的归属日期是订单创建日期不是支付日期。如果用户没有提供日期范围默认查询最近7天数据。结果模型经常自作主张地替用户补上一堆默认条件。后来我改成查询用户维度的销售数据参数必须显式提供日期范围缺少参数时返回参数错误。模型的调用准确率反而提高了。经验是描述要写“约束”和“边界”不要写“建议”和“背景故事”。模型不是人你给它越多的解释空间它就越容易自由发挥。5.3 流式事件被并发工具调用打乱顺序支持同时发起多个工具调用时事件顺序很容易乱。比如模型一口气要调用三个工具我们最初安排并行执行结果最后tool_result返回的顺序跟tool_call_start的顺序对不上。上层按顺序解析整个语义就错乱了。解决方案是用call_id把相同工具调用的事件串起来每个事件都携带payload[call_id]前端和上层组装的时候按call_id分组不再依赖全局顺序。这类问题在日志阶段几乎看不出来只有当你在同一个会话里发起多个并行工具调用时才暴露所以测试用例里一定要覆盖“一次多个工具调用”的场景。5.4 常见问题速查症状根因快速处理方式模型一直不选择某工具描述或参数Schema不完整把约束写进描述约束里参数表里补上枚举值工具能跑但返回结果模型不回返回内容没有回灌到上下文检查工具结果是否以消息形式追加进了历史多轮对话后上下文越跑越慢所有历史消息无脑堆积给context加摘要压缩策略滚动删除旧事件换一家模型服务商后大量报错适配层没兜住消息格式差异在适配层统一做消息格式归一化不要透传厂商对象模型调用工具名始终多空格名称归一化没处理适配层统一去除外部空格再匹配6. 调整过程中我最后想分享的几点体会这次改造最让我意外的是真正难的不是代码本身而是统一团队对“边界”的理解。有人习惯把接口定义在底层有人习惯让领域层直接操作基础设施这些在传统系统里可能只是风格差异在智能体系统里却会导致依赖关系彻底变形后面每加一个功能都变得异常痛苦。如果让我给一个起步建议我会说先从工具元数据“数据化”开始就是把所有能力的描述、参数、约束整理成结构化数据这一步成本很低收益最直接。第二步再去定义模型提供方的适配层把系统跟具体模型服务商隔离开。至于流式事件和上下文轨道的打通等前两步稳定了再做也不迟。最后分享一个小技巧给每个工具设置一个schema_version并且在首次上线时打上完整版本号。每次修改工具的参数定义就升一个版本。这样当某次模型行为突然变得异常时你能瞬间判断是不是缓存了旧工具描述导致的。这个习惯在长时间维护智能体系统的时候特别值钱别等线上事故了再补。