本文基于 AgentScope 2.0.6 源码,示例项目为 myagent(AgentScope + FastAPI + Postgres + WebSocket)。
一、还上一篇的债:表外的两条通道
能力进入 agent 只有两个原语:模型发起的调用,环境发起的注入 -- 而调用寄生在注入之上。
上一篇结尾埋了个问号:工具表装满了、预算见底了,怎么办?答案是绕开这张表。当时说了"表外还有两条通道",这篇就来还债。
先把"表外"说清楚。上一篇的一切--函数、协议、元工具--都发生在同一张扁平工具表里:能力以"可被 tool_call 的条目"身份进入 agent。表外的能力不进这张表:
- 有一种能力,模型不能调用它,只能阅读它--读完照着做,本事就长在了模型自己身上(说明书型);
- 还有一种办法,把"一整个 agent"当资源用--脏活发生在别人的上下文里,你的表和预算都不受污染(派生型)。
两条通道,加上一个容易被忽略的第三位成员(人),最终会归位成一个只有两条原语的框架--而那个框架还会再坍缩一次。先从最反直觉的派生型讲起。
二、派生:把另一个 Agent 当工具用
Sub-Agent 是主 Agent 眼里的一把特殊工具 -- 调用它 = 现场制造一个有独立上下文、独立提示、独立权限的新 agent。
先处理一个定义上的别扭:AgentCreate 明明是表内工具,凭什么说 Sub-Agent 在表外?因为工具买到的东西在表外--表内那一条只是"派生按钮",按下之后干活的是一个你上下文之外的完整 agent。它和表内工具的本质区别:
text
表内工具(上一篇) Sub-Agent(这一篇)
┌─ 主上下文 ──────────┐ ┌─ 主上下文 ──────┐ ┌─ 子上下文(用完即弃)─┐
│ 系统提示 ████ │ │ 系统提示 ████ │ │ 子的系统提示 ████ │
│ 对话历史 ██████ │ │ 对话历史 ██████ │ │ 派发的任务 █ │
│ 30 件工具定义 │ │ 结论 █(+200 tok) │◀─│ 搜索 50 个文件的 │
│ ████████████ │ └─────────────────┘ │ 中间垃圾 ████████████ │
│ 留给干活 ██ │ │ 结论 █ ──只有这个回传 │
└─────────────────────┘ └──────────────────────┘
脏活(读 50 个文件、试错、自我纠正)发生在子上下文里,用完即弃;主上下文只进一条结论。工具定义不占主上下文,权限还能收得更窄,模型也可以换 -- 三重收益,全部来自"隔离"这一个动作。
同步还是异步? 这是 Sub-Agent 实现的第一个分水岭。Claude Code 的 Task 工具是同步阻塞 的:派出去、等结果、拿到 tool_result 继续推理 -- 函数调用语义。AgentScope 的团队是异步消息 式的,成员之间靠 TeamSay 工具互发消息:
text
Claude Code(同步) AgentScope(异步团队)
主: Task(prompt) 主: AgentCreate(name, prompt)
│ 阻塞等待...... │ 立即返回 "Member added"
│ │ (本轮结束,该干嘛干嘛)
子: 后台跑完 子: 被消息总线唤醒,独立跑
主: 收到 tool_result(结论) 子: TeamSay(to=leader, 结论)
主: 结论进收件箱,下轮被唤醒读到
= 函数调用 = 邮件协作
异步换来了"三个 worker 并行干活、各自汇报"的表达力,代价是主 agent 不再天然"等"结果 -- 通信纪律要靠提示词维持(框架在 worker 的系统提示里写明:只有拿得出手的成果才值得 TeamSay 出去,内心戏留在自己的上下文里烧掉)。
AgentCreate 的完整时序。 myagent 里已经注册了一个翻译子 agent 模板(agents/translator.py),拿它当标本走一遍 AgentScope 源码(app/_tool/_agent_create.py)。Leader 调用 AgentCreate(name="translator", prompt="把这段翻成英文...", subagent_type="translator") 之后:
text
Leader 推理轮 AgentScope 运行时 Worker
│ │ │
│── AgentCreate ──────────▶│ ① 校验:在团队中?是 leader? │
│ │ 名字唯一?不含 @? │
│ │ ② 查模板注册表: │
│ │ type="translator" 命中模板 │
│ │ ③ 渲染 system prompt(5 占位符) │
│ │ ④ 建 AgentRecord + Session │
│ │ source="team"(列表隐藏) │
│ │ ⑤ 权限合成(三开关) │
│ │ ⑥ prompt 投递收件箱 + 唤醒 ──────▶ 立刻开跑
│◀─ "Member added" ─────────│ │(自己的 ReAct 循环)
│ (不等结果,本轮结束) │ │
│ │◀── TeamSay(to=leader, 翻译结果) ──│
│◀─ 收件箱通知,下轮读到 ─────│ │
三处设计值得咀嚼:
type 是职级,name 是工牌。 subagent_type="translator" 是模板路由键(开发者在 create_app 注册,变成工具 schema 里的 enum),name="translator" 是 leader 现场起的实例名(TeamSay 靠它寻址)。同一个岗位可以雇多个人 -- 模板与实例分离,和上一篇"连接与工具"的分离是同构的。
权限是合成的,不是拍脑袋的。 worker 的权限 = 模板权限 + leader 运行时权限,按三个开关合成(_types.py):
text
模板的 PermissionContext(translator 没配 -> 空)
+ leader 的 mode (override_leader_mode=False -> 继承)
+ leader 已授的规则 (extend_leader_permission_rules=True -> 追加)
+ leader 的工作目录 (extend_leader_working_directories=True -> 合并)
= worker 实际权限
动机很实际:用户在 leader 会话里已经批过"允许读文件",worker 不能再问一遍 -- 否则派三个 agent 要被问三次同样的问题。
worker 没有自己的模型。 看 _agent_create.py:497-499:worker session 的 chat_model_config 直接继承 leader 的,而 SubAgentTemplate 里根本没有模型字段 。所以 myagent 的 translator.py 里传入的 model_name,只被插值进了 description 的文字 -- 它并没有真的让翻译 agent 用上那个模型。想让"杂活用便宜模型"成立,得在派生后另行接线。这是一个容易踩的认知坑:模板定义的是说明书、权限和行为配置,模型是会话级的继承物。
三、注入:不进工具表的说明书
第二条表外通道更彻底:能力连"被调用"的资格都不要 -- 它是被阅读的说明书,读进去,本事就长在模型自己身上。
回到第四篇那张多来源列表。Toolkit 构造器的完整签名(tool/_toolkit.py:88-96):
python
Toolkit(
tools=[...], # 表内:函数
skills_or_loaders=[...], # ← 这一项:技能,表外
mcps=[...], # 表内:协议
tool_groups=[...], # 组织方式:分组
)
技能和工具平起平坐地坐在构造器里,却走完全不同的进入机制。框架自己怎么说--_toolkit.py:51-63 有一段注入进系统提示的模板,原文节选:
text
<agent-skills>
Skills are a collection of instructions, scripts, and resources
to extend your capabilities.
**IMPORTANT**: Skills are NOT tools, and you cannot call a skill
directly. To use a skill, you MUST use the `{{ skill_viewer }}`
tool to read the skill's full instructions, and then follow those
instructions to use the tools and resources provided by the skill.
# Available Skills:
<skill>
<name>...</name>
<description>...</description>
<dir>...</dir>
</skill>
</agent-skills>
第一句就划清了界限:Skills are NOT tools。它的机制和表内工具完全不同:
text
工具(表内) 技能(表外)
┌────────────────────┐ ┌────────────────────────────┐
│ schema 注入工具表 │ │ 目录注入系统提示: │
│ name+description │ │ name + description + dir │
│ + input_schema │ │ (每个技能几十字,常驻) │
│ │ │ │ │ │
│ ▼ │ │ ▼ 模型觉得某个技能相关时 │
│ 模型 tool_call │ │ 调用 Skill 查看器工具(名字 │
│ │ │ │ 就叫 "Skill")读取全文 │
│ ▼ │ │ │ │
│ 代码执行,结果回流 │ │ ▼ │
└────────────────────┘ │ 说明书全文进入上下文, │
│ 模型照说明书行事 │
└────────────────────────────┘
源码里那个查看器(tool/_builtin/_skill.py)是个再朴素不过的工具--入参一个技能名,出参是 target_skill.markdown 全文:
python
# SkillViewer.call(节选)
target_skill = skills.get(skill)
if not target_skill:
return ToolChunk(content=[TextBlock(
text=f"SkillNotFoundError: Skill '{skill}' not found.")],
state=ToolResultState.ERROR)
return ToolChunk(content=[TextBlock(text=target_skill.markdown)])
三个耐人寻味的细节:
其一,读说明书是免费的。 SkillViewer 的 check_permissions 恒返 ALLOW,源码注释写着 "The skill viewer is always allowed to be called" 。看说明书不需要任何人批准--需要批准的是说明书里教你的那些动作。(顺带一提,模板里的 {``{ skill_viewer }} 渲染后,就是那个名为 Skill 的查看器。)
其二,惰性注入是一份预算。 目录加取件的两段式,动机和上一篇的工具定义一样:说明书是常驻成本,每轮推理都带着。三十个技能的全文全注入,还没干活先吃几万 token;目录每个只占三行,谁被点名谁展开。你如果用过带技能目录的 AI 助手(比如 Claude Code 的 Skills),见到的就是这个机制。
其三,技能是个组合态:注入的目录 + 调用的取件。 目录是无条件注入的,全文是模型主动调工具拉的。这不是破坏了"表内/表外"的二分--恰恰相反,它是两种进入方式可组合的例证:用几十字的常驻注入做广告,用一次按需调用取货。
四、第三位成员:人,嵌在调用里的调用
表外还有一位容易被漏数的成员:人 -- 但严格说,人不是被模型调用的,是被权限体系嵌进调用链的。
看一个你项目里天天在跑的分支(tool/_adapters.py,MCPTool 的权限检查):
python
if self.is_read_only:
return PermissionDecision(
behavior=PermissionBehavior.ALLOW,
message="This is a read-only MCP tool. Allowing execution.")
return PermissionDecision(
behavior=PermissionBehavior.ASK,
message="MCP tools must be explicitly allowed by the user.")
ASK:工具执行前,权限引擎暂停流程,把决定权交给人。把"人"算作一个执行基质没有问题--生物脑确实在执行"判断"这个动作。但注意谁发起的问询:
text
普通调用: 模型 ──tool_call──▶ 工具 ──▶ 结果
人的座位: 模型 ──tool_call──▶ 工具 ──▶ 权限中间件
└──ASK──▶ 人 ──▶ 决定回流
发起问人的不是模型,是框架的权限引擎;模型只是"想调工具",拦路问人是中间件的行为。而且翻遍 _builtin/ 目录(bash/edit/read/write/glob/grep/powershell/meta/skill),没有 AskUser 这个工具 。所以诚实的说法是:人是嵌在调用里的调用,执行基质是人,发起者是框架。
五、归位:两条通道原来是两条原语
派生、注入、人 -- 三条表外通道加上表内的全部,只用了两个原语:模型发起的调用,环境发起的注入。
把这两篇的所有成员放进同一个框架:
text
原语一:调用(call) 原语二:注入(inject)
───────────────────── ─────────────────────
谁发起:模型 谁发起:环境(框架/平台)
形态:tool_call -> 执行 -> 回流 形态:内容直接进入上下文
时点:模型决定的任意时刻 时点:整载时/激活时/会话开始时
表内全部(函数/协议/元工具) 系统提示 = 最原始的注入
Sub-Agent(派生按钮在表内, 技能目录(<agent-skills> 块)
干活在表外独立上下文) 记忆与知识 = 第五篇深讲过的"整载"
人(权限引擎发起的嵌套调用)
注入系的成员你其实都见过:系统提示是每个 agent 收到的第一样东西,没人"调用"过它;记忆和知识是第五篇《会话状态的生命周期》深讲过的"整载"--会话历史、记忆索引在每条消息往返开始时被读进上下文,正是注入原语的日常形态。本篇只是给它们补发了身份证。
到这一步,"能力通道"的地图看起来是一棵两枝的树。先别急着收图--最后一节要亲手把它坍缩掉。
六、坍缩:调用寄生在注入之上
模型只能调用它先被注入过的一切 -- 两个原语不是并列的,是母子。
问一个刁钻的问题:模型怎么知道有个工具可以调?
答案在第五篇开头的整载图里,一直摆在那里:
text
整载(开跑前)
┌──────────────────────────┐
│ get_session 读一整行 │
│ model_validate 重建 │
│ 记忆索引 + 工具 schema │ ← 这一行
│ -> 动内存 │
└──────────────────────────┘
工具 schema,在整载时被注入。 每个 input_schema、每个 description,都必须先进上下文,模型才知道"有这么个东西、参数长什么样"。上一节的技能目录同理:目录不注入,模型连"有个技能"都不知道,更不会去调查看器。Bash 的降温段落、技能的三行广告、子 agent 的 description 路由--全是注入,全是调用的前提。
text
表面: 调用 ⇌ 注入 (并列二分)
深层: 注入 ──▶ 调用 (依赖单向)
┌─────────────────────────────────────────┐
│ 注入(母原语) │
│ 系统提示 ──── 万物之母的第一次注入 │
│ 工具 schema ─ 表内调用的入场券 │
│ 技能目录 ─── 查看器调用的入场券 │
│ 记忆/知识 ── 不是调用,是直接生效的注入 │
│ │ │
│ ▼ 模型看过目录之后 │
│ 调用(子原语) │
│ tool_call:函数/协议/Sub-Agent/元工具 │
│ (人:被权限引擎嵌进调用链) │
└─────────────────────────────────────────┘
系统提示是第一次注入,它让"你是谁"成立;工具 schema 是第二波注入,它让"你能做什么"成立;此后每一次 tool_call,都是注入的涟漪。
收图前修正最后一桩旧案。 第四篇讲工具系统时给过一张类图:ToolBase 下挂着 FunctionTool、MCPTool、ToolGroup。前两个没问题,第三个是简化 --源码里(tool/_tool_group.py:10)ToolGroup 不是 ToolBase 的子类,是个袋子:装着一批工具、技能、连接,靠 ResetTools meta 工具整体激活或停用。它和 FunctionTool 的关系不是兄弟,是货架和商品的关系。这篇讲的是"能力怎么进入"(通道轴),分组讲的是"进去之后怎么组织"(组织轴)--两根正交的轴,不该画进同一棵树。分类法里混轴,读者就会数出错误的成员数。
七、常见陷阱
陷阱 1:Sub-Agent 的 description 写成了"功能列表"
症状:Leader 派活派错类型,或者干脆自己干了不派活。
原因 :Leader 对 worker 的全部认知就是模板的 description(它被塞进 AgentCreate 的工具 schema)。写成"负责翻译相关工作"这种模糊描述,路由必然失准。
解决:description 写"什么任务该派给我"而不是"我是干什么的":"接受文本和目标语言,返回翻译结果"优于"翻译助手"。Sub-Agent 的定义是写作题,路由质量由文案决定。
陷阱 2:派发 prompt 忘了带上下文
症状:worker 干完活,leader 发现结果驴唇不对马嘴。
原因 :worker 看不到 leader 的对话历史 -- 它的世界从你派发的那条 prompt 开始。prompt="把这段翻成英文" 而没贴原文,worker 只能反问。
解决:派发 prompt 做到自包含(AgentCreate 的参数文档也这么强调):任务、背景、约束、交付物一次给齐。
陷阱 3:worker 干完不汇报,leader 干等
症状:团队创建成功、任务派发了,最后 leader 一轮轮空转。
原因:异步模式下没有超时追讨机制,worker 的汇报依赖它的系统提示里"干完要 TeamSay"的纪律。模型偶尔会"忘了"。
解决 :模板的 system_prompt_template 里显式写汇报要求;leader 侧不要轮询催促(TeamSay 的 leader 版描述明确警告了这一点),而是在收件箱驱动下自然醒来。
陷阱 4:把技能当工具注册
症状 :模型试图 tool_call("翻译技能"),收到工具不存在的错误。
原因 :技能不在工具表里。它的目录注入在系统提示的 <agent-skills> 块里,入口是名为 Skill 的查看器工具。
解决:想让模型用技能,靠的是目录里那三行 description 写得准--技能的路由质量和 Sub-Agent 一样,由文案决定。
八、收尾:通道、座位与源
上一篇讲工具表怎么装满,这一篇讲能力怎么绕开它:
- 派生:Sub-Agent--派生按钮在表内,干活在表外独立上下文,脏活用完即弃,只有结论过膜
- 注入:Skills--不进工具表的说明书,惰性注入(目录常驻,全文按需取),读完本事长在模型身上
- 人:嵌在调用里的调用--执行基质是人,发起者是权限引擎
- 两条原语:模型发起的调用,环境发起的注入--全部能力通道就这两种
- 坍缩:注入是母原语,模型只能调用它先被注入过的一切
- 勘误:分组是组织轴不是通道轴,ToolGroup 是袋子不是工具
下一篇把镜头拉到多个 agent 之间:编排。当星型团队不再是唯一选项,工作流(冻住的编排)、图编排、交接(handoff)各自适合什么形状的任务--上一篇的调用谱系表上有一个座位始终没设,工作流,它在新的一篇里等着。
延伸思考:注入的方向、目录的诚实、模板的模型
- 注入是单向的:环境能往上下文里塞东西,模型能不能"往回写"?(提示:想想第五篇的记忆写回--那是注入的逆过程,还是又一次调用?)
- 技能目录的 description 和 Sub-Agent 的 description,一个是注入系的路由文案、一个是调用系的路由文案--它们面对的"读者"有什么不同?
SubAgentTemplate刻意不带模型字段,worker 永远继承 leader 的模型 -- 这是简化还是设计?如果给模板加上模型字段,权限合成之外还要合成什么?