Sub-Agents、Skills与知识库:大模型应用架构重构实践
去年做一个复杂业务场景的大模型应用时我一开始图省事把所有能力塞进单个 Agent检索、生成、校验、执行全在一个上下文里跑。结果很快发现这条路走不通——prompt 越来越长模型在工具调用里频繁迷失多轮对话后甚至记不住最初目标。后来我痛下决心重构方向正是标题里那三件事Sub-Agents 角色分工、Skills 能力封装外加一个 V1 知识库。这篇文章是这次重构从设计到落地的完整记录包括角色怎么拆、权限怎么定、技能包怎么封装、知识库怎么选型与验证以及跑通后踩过的一堆坑。干货不少建议先收藏再读。1. 从单体 Agent 到 Sub-Agents我为什么要拆角色1.1 单体 Agent 跑 Demo 很爽上项目就露馅先说单体 Agent 的典型症状。第一个版本里我把二十多个工具全部塞进同一个系统提示词让模型自己判断该调哪个。Demo 场景下数据量小、任务单一表现还过得去。一旦进入真实业务问题就集中爆发了。第一个问题是上下文膨胀。一次完整任务往往要经过理解需求 → 检索资料 → 生成内容 → 校验修改几个阶段每个阶段的中间结果都堆在同一个上下文里。我实测过一次五千字左右的行业报告生成任务单轮结束上下文就吃掉了一万两千多 token再来一轮修改上下文接近两万 token。模型不是神上下文一长前面的指令就被冲淡了后面经常做出违背原始要求的操作。第二个问题是工具误选率急剧上升。工具清单超过一定的数量后模型在该用哪个工具这件事上的判断就开始飘。我统计过一组数据工具数量在 8 个以内时工具选择准确率能维持 90% 以上塞到 22 个时准确率直接掉到 72% 左右。这个下降不是因为模型变笨了而是它需要在庞大的工具描述里做判别注意力被无关工具分散了。第三个问题更致命权限没法隔离。一个只读的检索工具和一个会触发外部写入动作的工具放在同一个 Agent 里模型一旦理解偏差就可能在不该执行的操作上点了执行。我经历过一次因为工具描述相近导致的误调用虽然没有造成实质损失但吓得我把整个架构推倒重做。1.2 角色拆分的底层逻辑职责单一、上下文隔离、权限最小化既然单体 Agent 不行那就拆。拆的依据不是拍脑袋而是三个原则。职责单一。每个 Sub-Agent 只负责一件类型的事系统提示词从几千字缩到几百字。提示词短了模型对自身职责的理解就更稳定。这就好比一个全能选手接到任务后容易犹豫我该先做哪个而一个分工明确的团队里每个人都清楚自己该干什么。上下文隔离。每个角色的对话上下文独立存放A 角色的中间结果不会污染 B 角色的判断。角色之间通过结构化消息传递任务票据而不是把整段上下文直接扔过去。这个设计让单角色的上下文长度始终可控我跑了一周之后最长的单角色上下文也只有三千多 token。权限最小化。每个角色只能看到自己用得上的工具和数据范围。比如检索角色不需要生成最终交付内容那就干脆不让它挂载写操作类工具。权限不只是写在提示词里而是通过配置层硬性限制从机制上堵住越权。1.3 我最终确定的角色拓扑这里给出我当时最终定下来的角色分工供参考。V1 阶段一共四个角色加上一个调度器。我不画复杂的架构图直接建一张表看得更清楚。角色核心职责挂载工具知识库访问范围关键限制编排者Orchestrator接收原始任务、拆解子任务、汇总各角色结果、异常转派任务票据管理工具不可直接访问不执行检索与生成检索研究员Researcher从知识库和外部资料中查找信息返回带引用来源的结构化结果知识库检索工具、网页检索工具全部知识库集合不生成最终内容方案撰写者Writer基于检索结果撰写内容按需调用生成工具生成工具、格式化工具只读检索结果副本不直接访问原始知识库质检者Inspector校验输出内容与任务要求的一致性输出修改建议校验工具、评分工具引用源知识库不做内容生成四个角色之间不直接平级通信所有消息都经过编排者中转。最开始我也试过让检索角色直接给撰写角色传结果省一步中转但出了几次任务责任不清的问题后还是收回到了星型拓扑。中转多一次消息开销换来的是每一环都有据可查出了错能明确知道是谁的锅。这里有个重要的设计原则角色拆分的粒度不要一开始就铺得很细。先按检索 / 生成 / 质检这种粗粒度拆跑一段发现问题再往下细拆。我见过有人第一版就拆了十几个角色结果角色之间的协作协议比业务逻辑还复杂调试成本极高。V1 阶段四到六个角色是比较舒服的区间。2. 角色定义不是写 Prompt角色卡与权限边界的设计2.1 角色卡把角色描述成配置而不是一段话很多人理解角色定义就是写一段漂亮的 System Prompt。我在这次重构里换了个思路角色的行为约束尽量下沉到配置层提示词只保留身份说明 一句话职责概述。因为提示词是软约束模型可能忘配置是硬约束系统会强制执行。我定义角色卡用的是一份 YAML 配置核心字段长这样role: researcher name: 检索研究员 description: 负责从知识库和外部资料中查找信息返回带引用来源的结构化结果 tools: - kb_retriever - web_search allowed_kb: - product_docs_v1 - faq_v1 memory: false max_iterations: 5 max_tokens: 4000 guardrails: - 不得修改知识库内容 - 不得直接生成最终交付内容 - 检索结果必须附带引用来源ID逐段说明几个容易被忽略的点。description字段不要写成长篇大论我控制在 40 字以内。模型对短句职责描述的遵守度明显高于长句。真正复杂的行为约束放在guardrails和工具白名单里让系统执行时去校验而不是赌模型自觉。tools是白名单机制只声明能用的工具其他一律拿不到。工具注册表在启动时按角色的白名单动态生成该角色的可用工具集合再把集合注入到运行时。这样即使模型在生成回复时脑洞大开提到了一个没权限的工具调用层也会直接拒绝并返回错误。max_iterations很关键。它限制单个角色的最大执行轮数防止角色在某个分支上循环空转。我遇到过检索角色在查不到结果时反复用不同关键词重试的情况设了 5 轮上限之后超出的情况会主动返回检索未找到相关结果并把控制权交回编排者不再自己硬耗。2.2 权限边界的设定能不能做比想不想做更重要V1 的权限体系我分了三层分别对应不同粒度的控制。工具权限是一级角色只能调用白名单内的工具。知识库访问是二级细到哪些知识库集合可以被该角色检索比如质检者只能访问引用源知识库而方案撰写者根本不直接访问知识库它只拿检索结果的副本。写操作是三级任何涉及外部系统写入、配置修改的动作都由特定角色比如一个独立的执行角色独占其他角色一律不授予。这个写操作独占的设定一开始被团队里某位同事嫌麻烦但后来一次误触发事故证明了这个设计值回票价。权限校验我放在运行时调度层做而不是放在模型层做。也就是模型产出想调某个工具的意图后调度层先校验角色是否有权限调用没有权限直接拦截并返回明确错误码。这个拦截日志非常有用——它能告诉你模型有没有频繁试图调用越权工具如果频率高说明角色定义可能不够清晰或者任务分配不合适需要调整而不是放任。2.3 角色间的协作协议结构化消息不传大段文本角色之间的协作是这次重构里被低估的一块。最初角色之间直接传整理好的大段文字结果有三类问题上下文爆炸、信息丢失、责任不清。后来我引入了任务票据机制所有角色间的交接都通过一张结构化票据完成。{ ticket_id: T-20241107-003, task_type: retrieve_knowledge, from: orchestrator, to: researcher, priority: high, task_goal: 查询某型号设备在高温环境下的故障处理流程, context_refs: [/task_store/T-20241107-003/context.json], acceptance_criteria: 返回至少2条相关记录每条附来源ID, max_rounds: 5 }这个设计有几个好处。任务票据只携带结构化字段原始的大文本资料放在共享存储里用context_refs引用角色需要时按 ID 去取避免了上下文被大段历史记录瞬间塞满。验收标准前置角色拿到任务时就知道什么叫做完不会出现生成了一版完全不符合要求的内容后还自我感觉良好。来源追溯清晰每个结果都能查到是哪次任务、由哪个角色、用了哪个工具产出的。协作层的调度我做了个简单的事件循环编排者把票据发给目标角色目标角色完成后把结果票据回传编排者再决定下一步是继续派给另一个角色还是把结果汇总返回。这个流程是顺序执行的V1 阶段我没有让角色间真正并行因为并行会引入结果合并问题而 V1 的核心目标是先稳定跑通性能优化留到 V2。3. Skills 能力封装把工具调用变成可复用的技能包3.1 为什么我不用裸工具而要做技能包工具调用的粒度是个容易被忽略的设计点。裸工具是原子操作比如查询一条知识库记录执行一段代码。如果用裸工具模型每次都要自己组织原子操作的调用顺序相当于让一个厨子每次做菜都要从零回忆菜谱步骤一多必然出错。我遇到的典型问题是知识库检索这个动作看起来是单个操作实际上完整流程包含生成检索 query → 查向量索引 → 过滤元数据 → 重排序 → 格式化引用来源五个步骤。让模型自己组合这五步它经常跳过元数据过滤或者忘了格式化引用来源。把这一串流程封成一个技能后模型只需要调用一次技能剩下的内部流程由代码固定执行稳定性和速度都上来了。打个比方裸工具是一颗颗螺丝、齿轮和弹簧技能包是装配手册加装配好的模块。模型不需要知道齿轮怎么咬合只需要知道这个模块能帮我完成什么、参数怎么传、什么时候该用它。这也是技能包最重要的封装价值把模型的注意力从如何执行转移到何时调用。3.2 技能包的标准结构一套可被人和模型共同理解的目录我给技能包定了一个标准目录结构每个技能占用一个独立目录skills/ kb_retrieve/ SKILL.md # 模型可读的技能说明书 schema.json # 参数 JSON Schema run.py # 执行入口 requirements.txt # 依赖环境 examples/ demo_input.json demo_output.json其中SKILL.md是核心它面向模型而不是人类。里面的描述质量直接决定模型会不会正确调用。我写的模板包含几个必填部分## 技能名称 kb_retrieve ## 功能概述 从企业知识库检索与查询相关的信息支持按场景、产品线过滤返回结构化结果。 ## 何时调用 用户需要查询操作指南、故障排查、产品参数等知识库内容时调用。 ## 何时不该调用 - 用户只是闲聊不需要事实性信息时不要调用 - 用户需要实时公开信息如新闻、股价时不要调用应使用外部检索技能 - 不确定知识库是否有相关内容时仍应调用返回空结果也是一种有效结果 ## 参数说明 query: string, 必填自然语言查询问题建议直接使用用户原话 filters: object, 可选元数据过滤条件包含 scene、product_line 等字段 top_k: integer, 可选返回结果条数默认 5 ## 返回值 results: 数组每项包含 content、source_id、score、metadata 字段何时不该调用这个部分特别有用。我在调参时发现如果不明确写负面场景模型会在需要外部实时信息的时候强行调用知识库技能检索出一堆语义相关但内容完全过时的资料。写清楚负面清单之后误调用率明显下降。3.3 技能注册与动态注入机制技能包不是写好了就能用的需要一个注册加载过程。我在调度层实现了一个技能注册器启动时扫描技能目录逐个加载并校验最后构建出技能注册表。加载失败不能静默跳过必须显式打错误日志并标记该技能为不可用状态。class SkillRegistry: def __init__(self): self._skills {} self._failed_skills [] def load_from_dir(self, base_dir): for skill_dir in base_dir.iterdir(): try: skill Skill.from_dir(skill_dir) self._skills[skill.name] skill logging.info(f[SKILL] loaded: {skill.name} v{skill.version}) except Exception as e: failed_skill skill_dir.name self._failed_skills.append(failed_skill) logging.error(f[SKILL] load failed: {failed_skill}, error: {e}) def get_available_skills_for_role(self, role_name, role_tool_whitelist): return { name: skill for name, skill in self._skills.items() if name in role_tool_whitelist and name not in self._failed_skills }每个角色启动时调度器按角色卡里的tools白名单从注册表里筛出可用技能再把每个技能的SKILL.md、参数 schema 和一条示例拼接进该角色的系统提示词。这样角色模型既知道有哪些技能可用、参数怎么填又有示例可以参考比只给一个工具函数签名要友好得多。技能冲突也需要提前处理。当两个技能功能重叠时常见情况是一个是新版一个旧版或一个偏通用一个偏特定场景。我的做法是引入命名空间加版本号kb_retrievev1和kb_retrieve_v2作为不同技能名注册。同时在 SKILL.md 的何时调用里明确区分两者的适用场景比如 v1 偏通用检索、v2 偏特定产品线检索。模型在调用时靠描述区分不会混淆。3.4 技能包的版本管理与灰度加载技能包的更新迭代比代码版本更频繁因为很多调优需要实测才能发现问题。我采用的做法是每个技能目录里带一个版本号文件注册器加载时记录版本。新版本技能先以带后缀的技能名注册只在少量测试任务中启用跑一段时间对比新旧版本端到端任务成功率稳定后再切换到正式技能名并下线旧版。这个过程不复杂但要注意技能包的执行环境依赖也要纳入版本管理。我遇到过新版本技能在开发环境跑得好好的生产环境加载失败的情况最后发现是新加了一个依赖包没写进 requirements.txt。V1 阶段我没上容器化直接在虚拟环境里跑所以这个坑给了我一个教训技能包加载后的自检很重要最好在启动时跑一遍技能的简单自测用例确认环境可用再宣告加载成功。4. V1 知识库从零搭建选型、切分与检索验证4.1 先想清楚知识库到底解决什么问题知识库不是随便把一堆文档塞进向量库就完事的。在动手之前我先列了一个清单明确 V1 知识库要解决的核心问题让模型在回答特定领域问题时基于资料而不是基于想象。V1 阶段我选了三个知识域作为首批入库内容产品操作手册、常见故障排查指南、业务术语规范。这三个域的共性是文档结构相对规整、答案相对确定适合作为第一个验证场景。资料量也不大三个域的文档加起来原始文本大约二十多MB这个量级对向量库的压力可以忽略主要精力可以花在检索质量上。这里有个选择策略值得说不要一上来就追求大而全。知识库的质量和覆盖面需要平衡V1 阶段宁可让检索结果精准也不能让它像搜索引擎一样只求召回率高。先在小范围内把质量做透再逐步扩域比一开始铺几百个文档结果什么都查不准要强得多。4.2 文档切分策略别只按固定字数切文档切分是知识库检索质量的第一道关也是新手最容易忽视的环节。最开始我偷懒直接把文档按 512 token 固定长度切一段切完下一段从中间继续。跑了几轮检索测试后发现两个典型问题语义断章取义——一个完整操作流程被切成两半检索到的只是流程片段模型拿着不完整的资料只能生成不完整的回答冗余噪音——相邻切片内容高度重合检索结果里前三条其实都在讲同一件事。后来我改成按语义结构切分思路是能按结构切就优先按结构切。操作手册按二级标题、三级标题划分边界每个标题下的完整段落作为一个基础块表格单独提取成结构化记录不作为普通文本切分对于没有清晰标题的自由文本再用固定长度兜底但加了重叠区间。我调整后的切分参数是这样基础块目标大小 384 token重叠区间 64 token。这个参数不是拍脑袋定的我对比过 256、384、512、768 四个档位的检索效果384 在精准召回和上下文完整性之间最均衡。512 以上的块虽然上下文更完整但因为块内信息过多检索得分被无关信息稀释Top-1 命中率反而掉了十个百分点左右。4.3 向量化与索引构建切分好的文档块需要向量化。嵌入模型的选择我花了不少时间做对比测试测试方法不复杂准备了一批真实业务查询问题对每个问题召回的 Top-10 结果做人工相关性标注统计命中率。候选模型里通用型嵌入模型在业务词汇上表现平平后来换了一个针对中文长文本优化过的开源模型命中率从 68% 提升到 82%。这个差距是用评测数据说话的不建议只凭榜单印象选模型。索引层面我构建了带元数据的集合每一块文档入库时都附加了四个字段来源文档ID、所属域、文档版本号、更新时间。这些元数据后面在过滤环节用处很大。距离度量我用的是余弦距离索引参数按向量库的默认推荐值起步跑通后再按检索性能实测微调。4.4 检索质量的验证方法自己造一个评测集知识库建完不是直接上线的要先过一遍评测。我从真实用户咨询记录里抽取了 26 个典型问题作为评测集覆盖三个知识域。评测指标我用两个Top-5 相关命中率即前五条结果里有几条是相关答案无效召回率即前五条结果中有多少与问题无关或内容残缺。第一轮评测结果很不理想Top-5 命中率只有 61%无效召回率高达 27%。问题的根源有两个一是切分粒度偏细导致检索结果碎片化二是相似度阈值设得太低把一批语义沾边但内容无关的切片也放了进来。调优做了三件事。第一把重排序环节加上先用向量检索取 Top-20再用一个轻量级交叉编码器做精排取精排后的 Top-5 作为最终结果。这一步直接把命中率提升了 15 个百分点。第二硬性相似度阈值从 0.60 提到 0.72低于阈值的记录直接过滤无效召回率降到 8%。第三把评测集里出现的高频业务词加进切分的标题索引让模型检索时优先匹配标题字段进一步减少噪音。调优后的数据Top-5 命中率从 61% 升到 84%无效召回率从 27% 降到 6%。这个结果对 V1 阶段来说我已经比较满意了。评测这一环是知识库质量的守门员没有评测数据调优就全靠玄学有评测数据每一步调整都能看到明确的涨跌。4.5 V1 知识库的更新策略V1 我做了离线批量更新没有做实时增量写入。具体做法是每天晚上由定时任务扫描文档源目录把有变更的文档重新切分并更新向量索引。更新采用版本号机制同一来源文档的新版本向量写入后旧版本向量不在检索结果中展示。这个策略的取舍很清楚实时更新要处理写入期间的检索一致性问题V1 阶段不值得为此增加复杂度。离线更新每天一次对业务场景来说数据滞后最多一天完全可接受。等到 V2 再评估是否需要缩短更新周期。5. 全链路跑通后的排障记录那些崩溃瞬间与修复方案5.1 角色互相踢皮球任务在 Agent 之间循环流转第一个版本跑通大概两天后我发现一个诡异现象某些任务没有失败报告但也没有最终结果任务像消失了一样。查调度日志发现任务票据在编排者和检索研究员之间来回转手每转手一次任务类型就变一次最后变成了一串无限循环的票据互发。根因是编排者的角色职责描述太宽泛了。我原来给它写的职责里有处理各类异常情况结果模型在检索研究员返回空结果后判定这是需要进一步检索的异常于是把任务重新派回检索角色检索角色拿到的又是同一个任务执行后还是空再次转回去。两个角色互相认为对方该负责谁都不肯把空结果当成一个可接受的最终状态。修复方案是给任务票据的状态机加了一条硬性规则任务类型一旦确定为retrieve_knowledge就只允许流转到researcher任何角色不得修改任务类型空结果是一种有效终态负责生成结果的角色必须接收并继续后续流程。另外把编排者的 guardrails 加了不得代替其他角色执行具体任务这条约束。改完后循环问题彻底消失。5.2 权限设得太死任务直接卡死权限最小化的执行过程中我走过一个极端检索角色不能直接给方案撰写者传检索结果所有结果必须先回编排者再转发。结果出现了新问题——检索角色返回结果时没有编排者的指导意见撰写角色拿到结果也不知道该怎么处理任务挂起。这个问题的本质是协作通道和权限模型没有配合好。我修的方式是给角色的协作动作单独开一条内部通道角色之间的消息转发不需要经过工具权限校验但必须在调度层记录全量日志。也就是说模型能说话但不能做事工具权限管的是做事协作通道管的是说话两者分开管。修完后检索结果可以直接以结构化票据形式传给撰写角色撰写角色依据结果数据自行判断下一步流畅多了。5.3 知识库检索质量差召回一堆不相关内容知识库里查设备A的保修政策返回结果里全是在讲设备A的使用技巧。这问题在评测阶段已经暴露过但上线后换了一批更口语化的真实问题后又出现了而且比评测集表现更差。排查之后发现两个新情况。第一真实用户的问题非常口语化跟文档原文的表达差异很大纯向量相似度匹配容易跑偏。第二一些用户问题里带有多层意图比如设备A在高温下停机怎么办保修吗单次检索只能命中前半段后半段的内容完全没被召回。针对这两个情况我做了调整。口语化问题在送向量检索前先经过一次改写用大模型把用户原话改写成适合检索的规范化 query改写后的 query 再去检索。多层意图问题则是在调度层加了个意图拆分器检测到问题含多个子意图时拆成多个检索任务结果合并后再去重。这两项改动把线上检索的有效召回率拉回到了评测期的水平。5.4 技能包加载失败但全程静默这个问题藏得最深发现过程完全靠运气。某天跑一个任务时生成内容明显异常但整个链路没有任何报错。逐环节排查才发现方案撰写角色依赖的一个格式化技能根本没加载成功模型拿不到这个技能就自己发挥了一种不规范的输出格式。追溯根因技能包注册器在加载时遇到了依赖缺失异常但异常被捕获后只是记了一条 debug 级日志没有在启动汇总里明确标红。整个服务照常启动了只是这个技能消失了。修复方案是双管齐下。加载器改为显式返回加载状态启动时统一打印技能加载汇总表每个技能一行成功打 OK失败打 FAIL 并附具体原因。同时在技能注册表里增加不完整技能标记任何角色白名单里包含未加载成功的技能时调度层给出警告。从此再没有出现过技能静默缺失的情况。另外一个容易被忽略的细节技能包的执行环境要在加载时自检。我在技能加载流程里加了一个可选的selftest钩子技能可以声明一段自检用例加载完跑一遍确认环境可用。这个钩子成本很低但对防呆很有帮助尤其是依赖环境经常变化的项目。6. 跑通之后我的几点体会这套架构稳定运行了一个多月后我最大的体会是多 Agent 系统能不能跑好关键不在模型有多聪明而在约束和封装有多到位。角色卡明确职责边界权限配置把越权锁死在机制层技能包让模型只做决策不做流程拼接知识库用评测数据驱动调优而不是靠感觉——这几件事做好哪怕底层模型不是最强的整体系统表现依然稳定可靠。如果只能挑一件事给后来者建议我会选让失败的路径显性化。多 Agent 链路长最怕的就是某个环节静默出错接着往下传。加载要打日志、调用要留票据、异常要显式返回、评测要有数据四件套齐了出问题时你一定能定位到那一环出了什么错。这套架构的 V2 我准备做三件事把角色间的顺序执行改成有条件的并行、给知识库加增量实时更新、把技能包的版本管理接到统一配置中心。每一步都有明确动机也有 V1 阶段积累的问题清单做依据。等 V2 落地后再来和大家分享新的踩坑记录。