工具表之外 -- 派生、注入与两条原语

本文基于 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 下挂着 FunctionToolMCPToolToolGroup。前两个没问题,第三个是简化 --源码里(tool/_tool_group.py:10ToolGroup 不是 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 的模型 -- 这是简化还是设计?如果给模板加上模型字段,权限合成之外还要合成什么?
相关推荐
HIT_Weston4 小时前
181、【Agent】【OpenCode】TuiThreadCmd(类型增长)(编译&运行时)
人工智能·agent·opencode
DeepAgent6 小时前
AI Agent 工程实践(31):Agent 如何部署
agent
苏灿烤鱼7 小时前
官方终端 Agent 空降登顶,为什么最新版还是 alpha?
agent
nix.gnehc8 小时前
工具表是怎么装满的 -- 函数、协议与聚合
agent
HIT_Weston10 小时前
183、【Agent】【OpenCode】TuiThreadCmd(JS&TS 历史)
人工智能·agent·opencode
Flynt11 小时前
从 Claude Code 切到 Pi 跑了一阵,聊聊真实体感
agent·ai编程·claude
番茄不是西红柿kk12 小时前
deepseek-harness跨平台桌面端二开项目(附git仓库地址+安装包)
git·agent·codex·deepseek·deepseekharness
张忠琳13 小时前
【deepseek-harness】DSH 文档合辑 · 篇一:核心架构与概览
ai·agent·deepseek·harness