1. 从一次压测事故说起:父代理为什么会“精神分裂”
我在做一个客服工单自动质检系统的时候,第一次真切感受到父—子代理的上下文隔离有多重要。当时架构很简单:一个父代理负责解析工单、生成处理计划,然后动态调度三个子代理,分别负责情绪识别、问题归类、回复建议生成。单测和少量样例跑得挺顺,可一进入连续压测阶段就露馅了:明明是同一个入口、同一个工单内容,第二次跑出来的结果和第一次差得离谱;子代理被派去执行“补全收货地址”的简单任务,给出的回复里却带着上一个工单的情绪分析表。更离谱的是,某个子代理在回答里引用了父代理思考过程中才出现的候选方案,而这些方案本不该让它看到。
我打开链路追踪日志,逐段回看了上下文内容,原因非常清晰:所有子代理都继承了父代理的全量上下文。父代理每处理完一个工单,历史工单细节、之前子代理的完整输出原文、工具调用的原始返回,全都被拼进子代理的初始上下文里。子代理以为自己接到的任务是“处理当前这一单”,实际眼前却铺着几千行与本单无关的痕迹。面对这些杂音,它只能在海量文本里自己找特征,于是一旦某句话和当前任务语义相似,就被顺手当成了任务的一部分。
这事给我上了一课:父—子代理模式运行得好不好,不光取决于大模型本身的能力,更取决于任务单元之间上下文边界的设计。上下文隔离不是可选项,而是子代理架构里必须提前想清楚的底座。
1.1 事故现象:重复执行、串答案、越权引用
先把当时的现象列清楚,大家可以对号入座。
- 结果漂移:同一个输入在同一套流程下跑两次,子代理输出结构一致,但关键结论完全不同,且没有明显规律。
- 任务串味:A任务子代理的输出里出现B任务独有的实体词、结论、编号,仿佛前一个子代理没退场,还在后台“托梦”。
- 越权引用:子代理明确引用了父代理在阶段规划里提到的备选方案。备选方案本属于父代理的“中间想法”,根本不该暴露给子代理。
- 成本膨胀:单轮任务的 token 消耗随着处理工单数量增加而快速增长,几乎呈线性上翻,而不是稳定在一个可控区间。
这四条里,最隐蔽的是第三条。输出偶尔错一次还能靠重试掩盖,但成本膨胀和上下文串味会随着任务量增加不断累积,迟早把你最后一道防线击穿。
1.2 根因定位:共享上下文把任务边框打穿了
拆开看,问题出在两层。
第一层是父代理的“记忆惯性”。父代理为了维持对整体目标的把握,会把历史对话、中间推理、工具调用记录长期保留在自己的上下文里。这些信息对父代理是有用的,因为它需要全局视角。但如果把这一整包东西直接复制给子代理,就完全没有区别对待了。
第二层是子代理的“锚定效应”。大模型做生成任务时,上下文里那些看似无关、实则高亮的内容会产生强烈的锚定效果。哪怕你已经在系统提示里写了“只关注本次任务”,只要子代理的上下文里躺着另一个任务的表格和结论,它就很难不被吸附过去。这不是模型能力不够,而是信息噪声盖过了任务信号。越长的无关上下文,对输出质量的稀释越严重。
1.3 一句话拿走结论
父—子代理的上下文隔离,核心不是切断父子之间的通信,而是让每个子代理只看到完成自身任务所需的最小信息集,让父代理只接收子代理的结构化交付摘要。把这个结论记在心里,后面的设计和代码思路就顺了。
2. 先把要“隔离”的信息分类,再谈怎么切
很多人一听“上下文隔离”,下意识以为就是父子之间完全不见面、不传数据。真按这个思路做,子代理会变成瞎猜的孤岛,父代理也拿不到可用的结果。实际操作下来,更合理的做法是给信息分三档,按档位决定注入、压缩还是切断。
2.1 三档信息分类法:全局常量、任务输入、无关噪音
我习惯把父子之间流动的信息分成 A、B、C 三类:
| 信息类别 | 典型内容 | 对子代理的价值 | 隔离处理 |
|---|---|---|---|
| A 类:全局常量 | 项目目标、技术栈、命名规范、安全红线、输出模板 | 需要,但应精简 | 压缩成固定摘要,每次统一注入 |
| B 类:任务必要输入 | 本轮任务指令、输入数据引用、参考样例、验收标准 | 高度需要,直接决定产出质量 | 完整放入子代理初始上下文 |
| C 类:无关噪音 | 父代理历史对话、其他子代理完整日志、工具原始返回、探索失败的记录 | 基本无用,反而干扰 | 坚决切断,不进子代理上下文 |
很多翻车系统的问题,不是不知道 A 类和 B 类,而是把 C 类当成“保险起见”一起传了下去。你可能会想:就算带点历史信息,模型总不至于分不清吧?但真实场景会告诉你,它真的可能分不清。尤其在上下文窗口紧张、模型需要做长程聚焦时,多一段无关对话,就多一分被带偏的概率。
2.2 C 类信息为什么是“沉默杀手”
C 类信息不会每次都造成明显错误,但它会悄悄影响结果的质量和稳定性。比如前置子代理输出里有一处错误示范,后一个子代理读到时,很可能不自觉地模仿它的句式甚至结论;再比如父代理探索方案时提到“A 方向不太行”,子代理看到后,可能对该方向产生负面偏见,哪怕当前任务恰好需要它认真评估 A 方向。
C 类信息对 token 经济的挤压也相当可观。上下文窗口是有限的,塞进了大量历史,留给真正任务信息的空间就少了。但 LLM 又不会告诉你“我刚才看了太多无关内容导致没记住你的指令”,它只会给出一个看似合理、实则偏航的结果。所以你很难从单次输出判断是提示写得不好,还是上下文被污染了。这也是为什么我会建议:从设计上就让 C 类信息物理上无法进入子代理上下文,而不是靠模型自觉忽略。
2.3 隔离的“度”:不是越少越好
信息给得太多会污染,给得太少也会翻车。一个常见的反向错误是:为了隔离,只丢一句话给子代理,连必要输入、输出格式、工具权限都不描述。结果子代理反复猜测、频繁调用工具,最后交付一个结构完全不符合预期的结果。
隔离的“度”是:只包含 A 类和 B 类信息,并且把这些信息组织成一份可直接执行的任务简报。A 类帮助子代理理解通用规则,B 类帮助它聚焦当前交付,C 类全部留在父代理侧。这样既不会过度膨胀,也不会让子代理变成无头苍蝇。
3. 隔离的实际收益:从一份 Token 账单说起
有人觉得上下文隔离是“洁癖”,是过度设计。我用一个真实项目里的计算来说明,它省下的不是一点半点。
3.1 Token 经济账:同样场景,隔离后省下四分之三
假设一个父代理管理 5 个子代理,每轮任务调度时,父代理的完整上下文约 8k token。
- 不隔离:每个子代理都继承父代理完整的 8k token,5 个子代理一轮调度产生的输入 token 就是 40k。如果每个子代理还要执行多轮工具调用,比如内部再迭代 3 次,每一轮都会把这 8k 作为基础重算,累计膨胀会非常惊人。
- 隔离:每个子代理只拿到 1.6k 的任务简报 + 0.4k 的全局约束,合计 2k。5 个子代理一轮调度产生的输入 token 就是 10k,内部迭代 3 次后也只有 30k。
同一个业务场景,光输入成本就降到原来的四分之一左右。项目里的子代理数量越多、调度频率越高,这个差距越夸张。我见过一个跑批系统,隔离前后月度 token 成本从 8 万降到了 1.9 万,这就是把每一分预算都花在了刀刃上。
3.2 专注度红利:上下文越干净,子代理越不容易跑偏
成本还是显性收益,真正让我坚持隔离的是输出稳定性。对比着做了一组实验:同一道任务,一组子代理带全量父上下文,另一组只带任务简报。带全量上下文的那组,在 20 次运行里有 6 次出现了不同程度的任务串味;只带任务简报的那组,20 次运行没有一次引用了无关内容。
你可以把子代理想象成一位临时来帮忙的顾问。你给他一份列清楚背景、目标和约束的一页纸任务说明,他大概率能在半天内给你靠谱的结果。你要是把整个项目历年的会议纪要、聊天记录、失败复盘全甩给他,他反而不知道该从哪里下手。模型再强,也是“上下文驱动”的,输入什么料,就炒什么菜。
3.3 调试效率:隔离让问题可定位
还有一个很容易被忽略的收益:调试效率。子代理的上下文一旦保持“最小且确定”,你在回看日志时,很快就能判断是任务简报给得不清楚,还是模型生成本身出了问题。反之,如果子代理的上下文每次都混着父代理一大堆历史,一个输出异常可能要翻很久才能定位是从哪一层被污染的。
隔离带来的调试收益,本质上是把“黑盒”变成“半透明盒”。你清楚地知道子代理看到了什么,自然就更容易推断它为什么这么回答。
4. 边界设计:父代理必须给什么,必须留什么
隔离策略具体落到设计上,核心问题是边界。我把它拆成两个问题:父代理必须给子代理什么?父代理必须自己留什么?
4.1 必给项:任务简报的六个组成要素
一份合格的任务简报(Task Brief)至少要有六项内容:
- 任务目标:一句话说清子代理正在完成什么。比如“为订单模块产出完整的 RESTful API 设计”。
- 输入引用:明确输入数据的来源路径或摘要。不要让子代理自己满世界找数据。
- 工具白名单:子代理可以调用哪些工具、禁止调用哪些工具。比如允许“代码库检索”,禁止“发送外部请求”。
- 验收标准:什么样的输出算完成。比如“必须包含 OpenAPI 规范文件和变更影响清单”。
- 输出格式:最好给结构化 schema 或模板,避免子代理自由发挥。
- 安全底线:涉及敏感字段时必须脱敏、哪些接口绝对禁止调用。
这六项共同构成了子代理的“委托书”。有了委托书,子代理就不用回溯父代理的全局上下文去猜自己该干什么,它只需要从委托书出发。
4.2 必须留的:父代理的历史与思考链
父代理必须留在自己手里的,至少包括三类内容:
- 历史对话全量:父代理和用户的整个交互过程,属于父代理的记忆,子代理只需要知道当前这一步。
- 父代理的思考链:父代理在拆解任务时可能考虑过多条路径,哪些可行、哪些不可行。这些是决策过程,不是决策结果,交给子代理只会造成锚定污染。
- 其他子代理的原始输出:前置子代理的完整输出不该直接传给下一个子代理。父代理应该先把前置结果消化成结构化摘要,再决定是否以及如何把摘要传给后续子代理。
很多系统在处理“子代理串联”时偷懒,直接把上一个子代理的输出全文拼进下一个子代理的提示词。短期看是省了二次加工的成本,长期看是在不断放大上下文里的 C 类信息量。等到污染累积到阈值,整个流程就会到处冒烟。
4.3 推荐模式:委托书模式取代全量继承
与其给子代理一个“继承接口”,不如给子代理一个“委托书”。父代理在创建子代理时,只负责组装一份干净的 TaskBrief,子代理的所有上下文都从 TaskBrief 开始构建。子代理不读父代理的全局变量,而是通过明确的字段接收信息。
委托书模式最大的优势是:它把信息传递变成显式行为。你看到代码里哪里拼了 Prompt、哪里塞了字段,就能一眼判断哪些信息进了子代理上下文。不像“继承”这种隐式机制,信息是怎么流的,靠猜。
5. 用最小子代理骨架把隔离落到实处
理论说得再多,不落地都是空谈。下面给一个简洁的 Python 示例,展示如何在代码层面实现委托书模式。这个示例不绑定具体框架,langchain、llamaindex 或者自研调度器都可以套用。
5.1 定义 TaskBrief 数据契约
from dataclasses import dataclass, field from typing import Any, List, Dict @dataclass class TaskBrief: task_id: str # 子代理任务唯一标识 task_goal: str # 任务目标 required_inputs: Dict[str, Any] # 输入数据或引用 allowed_tools: List[str] # 允许调用工具白名单 forbidden_tools: List[str] # 禁止调用工具黑名单 output_schema: Dict[str, str] # 输出结构要求 constraints: List[str] = field(default_factory=list) # 附加约束 global_context: str = "" # 只放A类全局常量的精简摘要这里最关键的是global_context字段。它只允许存放压缩后的全局约束,比如项目技术栈、命名规范这类 A 类信息。父代理的完整历史对话绝不放进这个字段里。
5.2 上下文构建器:统一组装子代理提示词
def build_child_context(brief: TaskBrief) -> str: # 只拼接A类和B类信息,C类信息在入口处就被挡住 sections = [ "# 全局约定\n" + brief.global_context, "# 本次任务\n" + brief.task_goal, "# 输入数据\n" + str(brief.required_inputs), "# 工具边界\n" + f"允许:{brief.allowed_tools};禁止:{brief.forbidden_tools}", "# 输出要求\n" + str(brief.output_schema), ] if brief.constraints: sections.append("# 注意事项\n" + "\n".join(f"- {c}" for c in brief.constraints)) return "\n\n".join(sections)上下文构建器是隔离策略的核心落点。所有要进入子代理上下文的文本,都必须经过这一个入口组装。这样做的好处是:隔离规则只维护一处,不会分散到业务代码里。
5.3 使用示例:父代理调度子代理
# 父代理内部不再把完整 history 传给子代理 api_brief = TaskBrief( task_id="api_designer_01", task_goal="根据用户权限模块的需求文档,设计RESTful API,并输出OpenAPI规范。", required_inputs={ "module_requirement": "docs/user_permission_module.md" }, allowed_tools=["read_file", "search_codebase"], forbidden_tools=["execute_shell", "send_email"], output_schema={ "openapi_file": "openapi.yaml", "breaking_changes": "list" }, constraints=[ "所有接口遵循REST风格", "字段命名统一使用snake_case", "涉及手机号、邮箱等字段必须声明脱敏规则" ], global_context="项目:商城后台改造;技术栈:FastAPI + PostgreSQL;接口文档位置:docs/", ) child_context = build_child_context(api_brief)在 langchain 这类框架里,组装之后也就是转成消息对象的事:
from langchain.schema import SystemMessage, HumanMessage messages = [ SystemMessage(content=child_context), HumanMessage(content="请开始执行任务。如有信息不足,先尝试使用白名单内工具补充,再决定是否反馈。"), ]框架怎么调用模型并不重要,重要的是:子代理看到的内容,从头到尾都只有 A 类全局约束加上本次任务的 B 类信息。父代理那些思考过程、历史工单、其他子代理的日志,根本不在 messages 里,物理上就传不进去。
5.4 隔离的关键不只在“下发”,更在“回传”
下发隔离做得再干净,如果回传环节不做约束,父代理的上下文很快会再次膨胀。子代理在任务过程中可能产生大量中间日志、工具返回、调试信息,如果这些原样回传,父代理在后续调度其他子代理时,又要背负这几千甚至上万 token 的杂质。
我的做法是给子代理定义一个统一的结果模型,强制结构化回传:
@dataclass class SubAgentResult: task_id: str status: str # succeeded / failed / needs_review key_outputs: Dict[str, Any] # 结构化交付物 summary: str # 最多三句话的摘要 reference_paths: List[str] # 大体积结果落盘后的引用路径 error: str = ""父代理拿到SubAgentResult后,只把summary和key_outputs里的必要字段并入自己的上下文。原始日志、中间产物,直接写到文件或对象存储,只留下引用路径。这样一来,父代理的上下文增长被控制在一个很慢的节奏里,后续子代理也不会因为父代理这层堆积而遭殃。
6. 生产环境踩坑与排查清单
隔离设计上线后,真正考验人的是各种“看起来正常但隐隐不对”的边界情况。我整理了几条高频问题,大家可以直接对照排查。
6.1 现象对照表:这类问题大概率是隔离失守
| 现象 | 根因 | 修复方向 |
|---|---|---|
| 子代理输出里出现其他任务的专有名词 | 子代理继承了全量上下文,词汇污染 | 改为委托书定向组装 |
| 同一输入两次执行差异巨大 | 上下文里残留随机搜索或历史记录 | 检查上下文构建器是否只拼接 A+B 类 |
| Token 账单异常膨胀 | 每次下发都携带父代理全量历史 | 加日志记录子代理实际输入长度,对比增量 |
| 子代理回传大量原始日志 | 回传链路没有结构化约束 | 统一走 SubAgentResult 模型 |
| 父代理调度后续任务时被“带跑偏” | 前置子代理全量结果回传了 | 父代理侧只保留摘要和引用 |
6.2 我的几条调试手段
- 加一张上下文快照日志:每次创建子代理之前,把将要进入子代理上下文的文本长度、关键词列表打一条日志。这样一旦出问题,可以直接比对是哪次组装引入了不该有的内容。
- 写隔离断言:在开发环境做一次自动化断言,解析子代理的初始上下文,确保它不包含父代理历史中独有的某些关键词。相当于给隔离规则上了回归测试。
- 盯输入 token 曲线:接入模型调用日志后,重点观察每轮调度的平均输入 token 是否稳定。如果随着任务数量增长而明显上扬,说明某条链路又在偷偷堆历史了。
6.3 容易被忽略:工具返回同样需要隔离
最后提醒一个深坑:即使任务简报做得再干净,子代理在运行过程中还会调用工具,工具返回的数据会直接进入子代理上下文。如果工具定义不加以约束,比如允许子代理调用一个“网页全文抓取”工具,它可能会把大段无关页面内容拖进来,导致子代理当场“迷路”。
所以工具层也要做隔离设计。能返回摘要的,就别返回原文;能用结构化字段的,就别拼一坨文本;敏感信息能在工具出口过滤的,绝对不要留给子代理自己判断。父—子代理上下文隔离其实是一整套链路工程,从任务组装、工具返回、结果回传到状态记录,每一环都要守住边界。
这个内容后续还可以这样扩展:如果你要面对的是多层代理嵌套,可以考虑给每一层单独建一个上下文命名空间,层与层之间只通过 TaskBrief 和 SubAgentResult 交换数据,这样即便代理树很深,每一层也能保持清醒。