子 Agent 的上下文边界
前面的文章介绍了工具怎样进入 Agent 循环。子 Agent 也可以看成一种工具:主 Agent 把任务交出去,等待另一个 Agent 完成,再把结果放回自己的上下文。不过,普通工具通常只执行一个确定动作,子 Agent 却会建立自己的消息历史,反复调用模型和工具,直到任务结束。
因此,子 Agent 的关键并不是“再调用一次模型”,而是划分上下文边界。主 Agent 已经读过哪些文件、执行过哪些命令、做过哪些判断,子 Agent 是否需要全部知道?子 Agent 调用工具产生的大量内容,又有多少应该回到主 Agent?如果这两个方向都不加控制,子 Agent 不但不能节省上下文,反而会复制一份更长的历史。
Microsoft Agent Framework、OpenCode 和 Codex 都支持把任务交给另一个 Agent,但实现并不相同。MAF 提供一个很薄的 Agent 工具适配器,OpenCode 为子任务创建独立 Session,Codex 则允许新线程选择性继承父线程历史。三种实现正好对应三种常见的上下文策略。
子 Agent 不是父 Agent 的副本
一次子 Agent 调用至少包含四部分:任务说明、运行配置、会话历史和返回结果。这四部分经常被笼统地称为“上下文”,实际上应该分开处理。
| 内容 | 作用 | 常见处理方式 |
|---|---|---|
| 任务说明 | 告诉子 Agent 要完成什么 | 每次调用显式传入 |
| 运行配置 | 模型、工具、权限、工作目录和指令 | 继承、覆盖或使用子 Agent 自身配置 |
| 会话历史 | 用户消息、模型回答、工具调用与工具结果 | 不传、按轮次传或过滤后传 |
| 返回结果 | 子 Agent 交给主 Agent 的结论 | 作为工具结果或 Agent 间消息返回 |
最容易出问题的是把“继承配置”和“继承历史”混为一谈。子 Agent 可以使用与父 Agent 相同的模型和工作目录,却完全看不到父 Agent 的对话;也可以继承部分历史,但使用更严格的工具权限。源码中通常也是分别处理这些内容,而不是复制一个完整的父 Agent 对象。
上下文隔离的直接收益,是把探索过程留在子 Agent 内部。假设主 Agent 让子 Agent 查找某个接口的实现,子 Agent 可能读取几十个文件并尝试多次搜索。主 Agent 真正需要的通常只是接口位置、调用关系和几条证据,而不是全部搜索输出。子 Agent 的独立历史吸收了探索成本,返回值才进入主 Agent 的下一轮模型请求。
隔离也有代价。子 Agent 看不到父历史时,任务说明必须自包含,至少要写清目标、范围、约束和期望输出。如果只传一句“继续刚才的工作”,子 Agent 无法知道“刚才”发生了什么。另一种办法是让子 Agent 自己重新读取文件,以额外的工具成本换取更干净的边界。
MAF:把 Agent 包装成函数工具
MAF 最直接的做法是 AsAIFunction。实现位于 src/Microsoft.Agents.AI/AgentExtensions.cs,核心逻辑可以缩写为:
public static AIFunction AsAIFunction(
this AIAgent agent,
AIFunctionFactoryOptions? options = null,
AgentSession? session = null)
{
async Task<string> InvokeAgentAsync(
string query,
CancellationToken cancellationToken)
{
AgentRunOptions? agentRunOptions =
FunctionInvokingChatClient.CurrentContext?.Options?.AdditionalProperties
is AdditionalPropertiesDictionary properties
? new AgentRunOptions { AdditionalProperties = properties }
: null;
var response = await agent.RunAsync(
query,
session: session,
options: agentRunOptions,
cancellationToken: cancellationToken);
return response.Text;
}
return AIFunctionFactory.Create(InvokeAgentAsync, options);
}
对主 Agent 来说,这个子 Agent 与普通函数工具没有区别。模型看到工具名称、说明和一个 query 参数,调用后得到字符串结果。适配器没有读取父 Agent 的消息历史,也没有把父消息复制给子 Agent。父到子的主要输入只有 query,子到父的主要输出只有 response.Text。
子 Agent 的模型、系统指令和工具来自这个 AIAgent 自身。ChatClientAgent 在运行时还会合并默认 ChatOptions 与本次 AgentRunOptions,但这属于运行配置,不代表父历史被继承。AsAIFunction 唯一主动向下传递的是当前 FunctionInvokingChatClient 中的 AdditionalProperties,供调用方携带额外的运行参数。
session 决定子 Agent 自己是否延续历史。没有传入 session 时,每次函数调用都会创建新 Session,所以两次调用相互独立。传入固定 session 后,后续调用可以沿用该 Session 中的上下文。这里仍然没有“自动继承父历史”的步骤,Session 从哪里来、里面保存什么,由调用方决定。
MAF 的 XML 注释还专门提醒,共用固定 Session 的函数是有状态的,不应在多个会话中并发使用,也不应让并行工具调用同时写入同一个 Session。需要并行运行多个子 Agent 时,应当为每个任务创建独立 Session,而不是为了共享背景而复用同一个实例。
这种设计没有建立父子 Agent 树,也不提供内置的深度限制。它只是把 AIAgent.RunAsync 适配成标准函数调用。优点是接口简单,任何 Agent 都能成为另一个 Agent 的工具;代价是上下文组织完全由调用方负责。任务依赖父历史时,要么把必要事实写入 query,要么显式准备合适的 Session,不能期待框架自动判断该传哪些消息。
MAF 还提供 Workflow 和后台 Agent 等更完整的编排能力,但它们解决的是消息路由、并行和持久化问题,不是 AsAIFunction 这条调用链的默认语义。讨论子 Agent 的上下文时,先看清这个最小适配器,反而更容易理解边界在哪里。
OpenCode:新建子 Session,只传任务
OpenCode 把派生入口做成内置的 task 工具,主要实现位于 packages/opencode/src/tool/task.ts。工具参数包括任务说明 prompt、子 Agent 类型 subagent_type、用于显示的 description,以及可选的 task_id 和实验性后台标记。
当主 Agent 第一次发起任务时,TaskTool 会创建一个新的 Session:
const nextSession =
session ??
(yield* sessions.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
agent: next.name,
permission: [...childPermission, ...childToolDenies],
}))
parentID 记录父子关系,方便查询、展示和计算派生深度。它不是历史继承开关。真正启动子 Agent 时,代码把 params.prompt 解析成 Parts,然后投递到 nextSession.id:
const parts = yield* ops.resolvePromptParts(params.prompt)
const result = yield* ops.prompt({
messageID: MessageID.ascending(),
sessionID: nextSession.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
variant: next.model ? undefined : variant,
agent: next.name,
parts,
})
这里没有父 Session 的消息数组。新 Session 的第一份任务上下文就是这次 prompt,再加上子 Agent 自己的系统提示和工具定义。子 Agent 运行的仍然是完整的 Session Prompt 循环,所以它可以自己读取文件、搜索代码和执行工具,并不是只能回答一次的简化模型调用。
运行配置采用“能继承的继承,需要收紧的收紧”。如果子 Agent 配置了模型,就使用自己的模型;否则继承触发当前任务的模型与 Provider,Variant 也随父调用传入。子 Session 与父 Session 处在同一个 OpenCode 实例和工作区环境中,因此可以访问同一项目,但能否使用某个工具还要经过权限规则。
权限不会原样复制。deriveSubagentSessionPermission 只从父 Session 继承 deny 和 external_directory 规则,再叠加子 Agent 自身权限。默认还会禁止 todowrite 和 task,除非子 Agent 明确配置了相应权限。这样既不会让父 Agent 已经禁止的操作在子 Agent 中重新开放,也能避免子 Agent 无限制地继续派生。
除了权限限制,task 还会沿 parentID 向上计算层级。默认 subagent_depth 为 1,主 Agent 可以创建子 Agent,子 Agent 不能继续创建下一层。这个限制和默认禁止 task 形成两道约束:一处控制树的深度,一处控制模型是否看得到派生能力。
子 Agent 完成后,OpenCode 不会把它的全部消息和工具记录合并回父 Session。runTask 只查找最后一个文本 Part:
return result.parts.findLast((item) => item.type === "text")?.text ?? ""
这段文本随后被包装在 <task_result> 中,成为主 Agent 的工具结果。可选的 task_id 则允许下一次调用继续使用之前的子 Session。它延续的是子 Agent 自己的历史,不会改变父子历史相互隔离的事实。
实验性的后台模式也遵守相同边界。调用先返回运行状态,任务结束后再把 <task_result> 作为 synthetic: true 的文本注入父 Session。前台等待和后台通知改变的是调度方式,不改变传入子 Agent 的上下文内容。
OpenCode 的选择很明确:父子关系保存在数据结构里,历史却不沿父子关系自动流动。主 Agent 必须给出足够完整的任务说明,子 Agent 则通过重新读取项目获得细节。这样会产生少量重复探索,但主 Session 不会承担子 Agent 的工具过程,也更容易单独恢复、取消或继续某个子任务。
Codex:独立线程与可选择的历史 Fork
Codex 的子 Agent 不是一个临时函数调用,而是由 AgentControl 管理的独立 CodexThread。父子线程共享控制面和注册表,每个线程却有自己的会话状态。模型通过 spawn_agent 创建线程,再通过等待、发送消息和关闭等工具管理它。
Codex 源码同时保留了两套多 Agent 接口,需要分开理解。v1 的 spawn_agent 使用 fork_context,默认不复制父历史;设置为 true 时才使用完整历史 Fork。v1 还提供 send_input、wait_agent 和 close_agent,调用方拿到 Agent ID 后可以继续投递任务、等待结果或关闭线程。
v2 使用 AgentPath 标识树中的 Agent,并把历史选项改成 fork_turns。它接受 none、all 或表示最近轮数的正整数字符串。当前 multi_agents_v2/spawn.rs 中的默认值是 all:
let fork_turns = self
.fork_turns
.as_deref()
.map(str::trim)
.filter(|fork_turns| !fork_turns.is_empty())
.unwrap_or("all");
if fork_turns.eq_ignore_ascii_case("none") {
return Ok(None);
}
if fork_turns.eq_ignore_ascii_case("all") {
return Ok(Some(SpawnAgentForkMode::FullHistory));
}
因此,不能笼统地说 Codex 子 Agent 默认没有父上下文。准确说法是:v1 默认不 Fork,v2 默认 Fork 全部历史,调用时也可以明确选择不 Fork 或只 Fork 最近若干轮。
Fork 也不是把父线程的 JSONL 原样复制一份。agent/control/spawn.rs 中的 keep_forked_rollout_item 会过滤历史。系统、开发者和用户消息会保留,Assistant 消息只保留标记为最终回答的内容。Reasoning、函数调用、函数结果、本地 Shell 调用、工具搜索结果和 Agent 间通信不会作为普通历史重放。完整历史 Fork 可以保留 TurnContext 与 WorldState,截断到最近若干轮时则要求子线程重新建立这些上下文。
match item {
RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => {
match role.as_str() {
"system" | "developer" | "user" => true,
"assistant" => *phase == Some(MessagePhase::FinalAnswer),
_ => false,
}
}
RolloutItem::ResponseItem(
ResponseItem::Reasoning { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::LocalShellCall { .. },
) => false,
RolloutItem::InterAgentCommunication(_)
| RolloutItem::InterAgentCommunicationMetadata { .. } => false,
RolloutItem::TurnContext(_) | RolloutItem::WorldState(_) => {
preserve_reference_context_item
}
RolloutItem::Compacted(_)
| RolloutItem::EventMsg(_)
| RolloutItem::SessionMeta(_) => true,
_ => false,
}
这层过滤非常重要。父 Agent 已经通过工具输出得到结论时,子 Agent 通常需要知道用户目标和已经确认的结果,不需要重放完整命令输出,更不应该继承尚未完成的工具调用。Codex 的 Fork 继承的是经过整理的模型上下文,不是运行日志的逐项克隆。
除了历史,Codex 还会从父 Turn 构造子线程配置,包括模型、Provider、Reasoning 配置、Developer Instructions、审批策略、权限 Profile、工作目录和环境选择。调用参数可以覆盖模型、推理强度、Service Tier 和 Agent Role。可见,历史 Fork 与配置继承是两条独立路径:fork_turns="none" 不等于子 Agent 会失去工作目录和工具能力。
v2 创建线程后,会用结构化的 InterAgentCommunication 投递初始任务。消息中包含发送方和接收方的 AgentPath、任务内容以及是否触发新 Turn。子 Agent 因而知道任务来自哪里,也可以通过控制面继续与其他 Agent 通信。v1 完成监听器会向父线程注入完成消息,v2 则使用 Agent 间通信和等待机制报告状态与结果。两种版本都不会把子线程内部的所有工具记录直接并入父线程。
Codex 还在控制面设置了资源边界。AgentRegistry 在创建线程前预留名额,配置中的最大线程数限制整棵树的规模;子线程深度由父 SessionSource 加一,超过最大深度便拒绝继续派生;执行限流器再约束同时运行的子线程数量。相比 OpenCode 的一层父子 Session,Codex 更适合持续存在、可以互相通信和恢复的 Agent 树,但控制面的复杂度也明显更高。
上下文应该传多少
把三个实现放在一起,可以看到它们并不是在争论同一个 API,而是在选择不同的默认成本。
| 维度 | MAF AsAIFunction | OpenCode task | Codex spawn_agent |
|---|---|---|---|
| 子任务载体 | query 字符串 | prompt Parts | 初始消息或 Agent 间消息 |
| 子运行实例 | AIAgent.RunAsync | 独立子 Session | 独立 CodexThread |
| 父历史 | 不自动复制 | 不复制 | v1 可选完整 Fork,v2 可选 none、all 或最近 N 轮 |
| 配置处理 | 使用子 Agent 配置,透传额外属性 | 子模型可覆盖,否则继承父模型,权限会收紧 | 从父 Turn 构造配置,Role 与调用参数可覆盖 |
| 返回方式 | response.Text | 最后一个文本 Part 包装为任务结果 | 状态等待与 Agent 间通信 |
| 递归控制 | 适配器本身没有 | Session 深度与权限规则 | 树深度、线程数与执行限流 |
如果子任务可以通过项目文件重新获得全部事实,OpenCode 式的空白 Session 最稳妥。任务说明只要给出目标和范围,子 Agent 自己调查,主 Agent 只接收结论。这种方式特别适合搜索、代码审查和测试结果分析,因为这些任务会产生大量临时工具输出。
如果子任务高度依赖刚刚形成的对话决策,完全隔离会迫使主 Agent 重写很多背景。Codex 的最近 N 轮 Fork 更合适,但 Fork 前应过滤工具调用、工具结果和中间推理,只保留用户目标、稳定指令和已经确认的结论。Codex 的源码说明了一个实用原则:继承历史不等于继承运行痕迹。
如果系统只需要偶尔调用一个专家 Agent,MAF 的函数适配器已经足够。调用契约保持成一个任务字符串和一个结果字符串,Session 是否延续由应用自己决定,没有必要先引入完整的 Agent 树和通信协议。
无论采用哪种策略,任务说明都不应只写动作。一个可靠的子任务至少包含下面四项:
- 要解决的具体问题。
- 可以读取或修改的范围。
- 已经确认且不必重复调查的事实。
- 返回结果需要包含的证据和格式。
返回内容也要受控。子 Agent 的历史即使与主 Agent 完全隔离,一份几千行的最终报告仍会一次性占满主上下文。让子 Agent 返回结论、关键路径、风险和必要证据,比返回全部过程更符合派生的目的。需要继续追问时,应当复用子 Session 或线程,而不是第一次就要求它把所有细节都带回来。
本文涉及的主要源码入口如下:
- MAF:
src/Microsoft.Agents.AI/AgentExtensions.cs、src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs、src/Microsoft.Agents.AI/ChatClient/ChatClientAgentSession.cs。 - OpenCode:
packages/opencode/src/tool/task.ts、packages/opencode/src/agent/subagent-permissions.ts、packages/opencode/src/session/prompt.ts。 - Codex:
codex-rs/core/src/tools/handlers/multi_agents、codex-rs/core/src/tools/handlers/multi_agents_v2、codex-rs/core/src/agent/control/spawn.rs、codex-rs/core/src/agent/registry.rs。