深入理解 Subagent 的实现原理

0 阅读24分钟

1. 前言:什么是 Subagent ?

在前文讲解 Agent Skills 时我们提到,通过编写提示词驱动大模型工作会产生大量重复内容,为此设计了按需加载的 Skills 系统以降低 Token 消耗。然而即便如此,大模型在多轮对话后,其上下文窗口依然被不断膨胀的对话历史与中间结果挤占,导致响应变慢、事实遗忘、指令遵循能力下降。

因此 AI Agent 需要从“单兵作战”走向“协同系统”,Subagent(子代理) 模式应运而生。

Subagent(子代理)  是一种设计模式:主代理(Orchestrator)负责理解用户意图,将特定任务“委托”给一个全新上下文的子代理去执行,子代理完成后只返回一个简洁的摘要。

这种模式的好处显而易见:

  • 上下文隔离:子代理的工作不会污染主对话历史,避免无关信息干扰。
  • 专注单一职责:每个子代理可以针对特定任务优化系统提示和工具集。
  • 可复用与扩展:可以像插件一样增加新的子代理,主代理无需修改核心逻辑。

本文将通过三段循序渐进的代码示例,深入剖析 Subagent 的实现原理——从最基础的同步单层委托,到异步消息驱动的并发架构,再到基于配置文件的动态插件系统。

2. Subagent 的核心设计理念

2.1 工具调用基础:大模型如何调用函数

要理解 Subagent 的实现原理,必须吃透 OpenAI 风格的工具调用(Function Calling)是如何与代理循环结合的。大模型调用工具的标准流程包含以下 7 个步骤:

  1. 定义工具:用 JSON Schema 描述函数名称、功能、参数。
  2. 附加到请求:将工具定义随用户消息一起发送给模型。
  3. 模型判断:模型根据用户输入判断是否需要调用工具,以及调用哪个工具和参数。
  4. 解析响应:从模型返回的 tool_calls 字段中提取工具名称和参数。
  5. 执行本地函数:在代码中调用对应的函数,获得执行结果。
  6. 回填结果:将结果封装为 tool 角色的消息,追加到对话历史中。
  7. 模型生成最终答案:模型结合工具结果生成面向用户的回答。

以下是一个简单的 AI Agent 实现,完整地展现了这一过程:

import os
import json
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI

# 加载环境变量(如 DEEPSEEK_API_KEY)
load_dotenv()

# ---------- 工具定义 ----------
tools = [
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "读取文本文件内容。",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "要读取的文件路径"},
                    "encoding": {"type": "string", "enum": ["utf-8", "gbk"], "description": "文件编码格式"}
                },
                "required": ["path"]
            }
        }
    }
]

# ---------- 工具实现 ----------
class ReadFileTool:
    def execute(self, path: str, encoding: str = "utf-8") -> str:
        try:
            file_path = Path(path).expanduser()
            if not file_path.exists():
                return f"❌ 文件不存在: {path}"
            return file_path.read_text(encoding=encoding)
        except Exception as e:
            return f"❌ 读取失败: {str(e)}"

file_tool = ReadFileTool()

# ---------- 初始化客户端 ----------
client = OpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com"
)

SYSTEM = "你是一个文件读取助手,必要时可以调用工具帮助用户读取文件内容。"
MODEL = "deepseek-chat"

def agent_loop(messages: list):
    while True:
        response = client.chat.completions.create(
            model="deepseek-chat",
            messages=messages,
            tools=tools,
            tool_choice="auto"
        )
        msg = response.choices[0].message
        
        # 添加助手的回复
        messages.append(msg)
        
        # 如果模型没有调用工具,说明已完成任务
        if not msg.tool_calls:
            return msg.content

        # 否则,执行每个工具调用并收集结果
        for tool_call in msg.tool_calls:
            if tool_call.function.name == "read_file":
                args = json.loads(tool_call.function.arguments)
                print(f"\033[33m🔧 调用工具: {tool_call.function.name}, 参数: {args}\033[0m")
                result = file_tool.execute(**args)
                print(f"✅ 工具执行结果:\n{result[:200]}\n")
                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "name": tool_call.function.name,
                    "content": result
                })

if __name__ == "__main__":
    history = [
        {"role": "system", "content": SYSTEM}
    ]
    while True:
        try:
            query = input("\033[36m用户 >> \033[0m")
        except (EOFError, KeyboardInterrupt):
            break
        if query.strip().lower() in ("q", "exit", ""):
            break
        
        history.append({"role": "user", "content": query})
        final_answer = agent_loop(history)
        
        if final_answer:
            print(f"\033[32m助手: {final_answer}\033[0m\n")

上述基础 AI Agent 系统也是我们所有的文章所用到的基础代码。可以看到单体 Agent 将所有逻辑塞进一个对话循环,随着任务复杂度增加,对话历史会急剧膨胀,导致响应变慢、事实遗忘、指令遵循能力下降。

2.2 从单体到 Subagent:引入 task 工具

为了解决单体 Agent 的上下文膨胀问题,我们可以定义一个 task 工具。当主 Agent 判断某个子任务可以被独立执行时,它调用 task 工具,我们则启动一个拥有全新独立上下文的子 Agent 去执行该任务,子 Agent 完成后只返回一个简洁的摘要给主 Agent。这样主 Agent 的对话历史中只保留了任务调用和最终摘要,中间的所有工具调用、推理过程都被隔离在子 Agent 中。

实现这一模式的核心代码改动如下:

# 省略...

# ---------- 工具定义 ----------
+ tools = [
+    {
+        "type": "function",
+        "function": {
+            "name": "task",
+            "description": "生成一个子代理任务。对于可以独立运行的复杂或耗时任务,请使用此功能。子代理将完成任务并在完成后进行汇报。",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "prompt": {"type": "string", "description": "子任务提示词"},
+                    "description": {"type": "string", "description": "任务的简短描述"}
+                },
+                "required": ["prompt"]
+            }
+        }
+    }
+]

- tools = [
+ child_tools = [
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "读取文本文件内容。",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "要读取的文件路径"},
                    "encoding": {"type": "string", "enum": ["utf-8", "gbk"], "description": "文件编码格式"}
                },
                "required": ["path"]
            }
        }
    }
]

# 省略...

- SYSTEM = f"你是文件读取助手,必要时可以调用工具帮助用户读取文件内容。"
+ SYSTEM = f"你是AI助手,使用任务工具来委派探索性工作或子任务。"
+ SUBAGENT_SYSTEM = f"你是AI助手的编码子代理。完成给定任务后,总结你的发现。"
MODEL = "deepseek-chat"

+ def run_subagent(prompt: str) -> str:
+    # ① 全新上下文:与父代理完全隔离
+    sub_messages = [
+        {"role": "system", "content": SUBAGENT_SYSTEM},
+        {"role": "user",   "content": prompt}
+    ]

+    for _ in range(30):  # ② 安全上限
+        response = client.chat.completions.create(
+            model=MODEL,
+            messages=sub_messages,
+            tools=child_tools,     # ③ 受限工具集
+            tool_choice="auto"
+        )
+        msg = response.choices[0].message

+        # ④ 追加助手消息(msg 对象直接追加)
+        sub_messages.append(msg)

+        # ⑤ 终止条件:无工具调用则任务完成
+        if not msg.tool_calls:
+            return msg.content or "(无摘要)"

+        # ⑥ 执行工具调用,回填结果
+        for tool_call in msg.tool_calls:
+            if tool_call.function.name == "read_file":
+                args = json.loads(tool_call.function.arguments)
+                result = file_tool.execute(**args)
+                sub_messages.append({
+                    "role": "tool",
+                    "tool_call_id": tool_call.id,
+                    "name":         tool_call.function.name,
+                    "content":      result
+                })

+    return "(无摘要)"  # ⑦ 循环超限兜底

def agent_loop(messages: list):
    while True:
        # 省略...
        # 否则,执行每个工具调用并收集结果
        for tool_call in msg.tool_calls:
+            if tool_call.function.name == "task":
+                args = json.loads(tool_call.function.arguments)
+                desc = args.get("description", "子任务")
+                print(f"\033[33m🧩 task ({desc}): {args['prompt'][:80]}\033[0m")
+                result = run_subagent(args["prompt"])
+                print(f"✅ 子代理摘要:\n{result[:200]}\n")
+            else:
+                result = f"未知工具: {tool_call.function.name}"
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": str(result)
            })

# 省略...

上述代码实现了一个最朴素的 Subagent 模式:主代理拥有一个名为 task 的工具,调用时启动一个同步子代理

  • 主代理的 agent_loop

    1. 调用 LLM,根据对话历史决定是否需要调用工具。
    2. 如果模型返回 tool_calls,则逐个执行工具。
    3. 遇到 task 工具时,调用 run_subagent(prompt) 函数。
    4. 将子代理返回的摘要作为 tool 消息追加到主对话中,然后继续循环。
  • 子代理执行函数 run_subagent

    • 创建一套全新的消息列表(只包含系统提示和用户任务),实现上下文隔离。
    • 进入自己的工具循环,拥有独立的工具集(本例中为 read_file),实现了权限分离
    • 设置了 for _ in range(30) 作为有限步数保护,防止 LLM 陷入无限工具调用(如反复读取不存在的文件)。
    • 子代理完成后返回内容摘要(或“无摘要”兜底)。

我们可以在根目录下创建 test.txt,写入以下内容:

我是程序员 Cobyte
我正在写《AI Agent 基础知识入门实践》教程

然后启动程序,输入任务:“读取 test.txt 文件,统计英文和中文各自的文字总数,以及中文和英文的文字总数是多少?” 子代理将独立完成文件读取和统计,主代理最终输出结果。

结果输出如下:

01.png

2.4 工具化包装——Agent as Tool

现在我们来总结一下上述 Subagent 的核心设计理念,主要是通过把子代理包装为一种特殊的“工具”(Tool),供主代理的标准工具调用机制使用。具体做法是:将子代理的调用接口封装成一个 Tool 对象,该 Tool 的 namedescription 字段描述了子代理的能力,run 方法内部实现上下文创建、子代理启动、结果等待与摘要提取。

主代理完全不知道它是一个子代理还是普通函数——它只看到“这是一个工具,我可以调用它并得到返回”。这种设计模式被称为 Agent as Tool,极大地简化了框架的实现复杂度,并且天然支持主代理同时调用多个工具(即并行执行多个子代理)。

2.5 同步 Subagent 的瓶颈

上述实现虽然有效隔离了上下文,但存在一个明显的同步阻塞问题:当主代理调用 run_subagent 时,整个程序会卡住直到子代理完全执行完毕。在此期间,用户无法输入新的指令,程序也无法响应其他并发请求。对于需要读取大文件、进行多轮搜索或调用外部 API 的耗时子任务,这种同步模式会导致糟糕的用户体验。

为了克服这一限制,我们需要将 Subagent 改造为异步非阻塞模式——让主代理在子代理执行期间能够继续处理新的用户消息,子代理完成后通过回调或消息队列将结果推送给主代理。这正是下一节将要讨论的内容。

3. 异步子代理(Async Subagents)

上一节的同步 Subagent 虽然有效隔离了上下文,但 run_subagent 调用会阻塞整个主循环——在子代理执行期间,用户无法输入新指令,程序也无法处理其他请求。为了解决这一瓶颈,我们需要引入异步非阻塞架构。本节将基于上述代码,实现并剖析异步 Subagent 的核心设计。

3.1 异步基础设施:从 OpenAI 到 AsyncOpenAI

异步改造的第一步是将同步的 OpenAI 客户端替换为 AsyncOpenAI,并将所有 I/O 操作改为 await

import os
import json
+ import asyncio
from pathlib import Path
from dotenv import load_dotenv
- from openai import OpenAI
+ from openai import AsyncOpenAI

# 省略...

# ---------- 初始化客户端 ----------
- client = OpenAI(
+ client = AsyncOpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com"
)

# 省略...
- def run_subagent(prompt: str) -> str:
+ async def run_subagent(prompt: str) -> str:
    # 省略...
    for _ in range(30):
-        response = client.chat.completions.create(
+        response = await client.chat.completions.create(
            model=MODEL,
            messages=sub_messages,
            tools=child_tools,
            tool_choice="auto"
        )
        # 省略...

run_subagent 变为 async def 后,内部的所有 client.chat.completions.create 调用前都加上 await。这样,当子代理等待 LLM 响应或执行文件读取时,事件循环可以切换到其他任务(例如处理新的用户消息),从而实现非阻塞。

3.1 双队列架构:解耦输入与输出

同步版本的问题根源在于 消息处理与用户输入共享同一个线程。异步版本通过引入两个 asyncio.Queue 将两者彻底分离:

  • 入站队列 inbound:负责接收用户消息。用户每输入一条指令,主协程立即将其 put 进队列,然后可以继续处理下一轮输入,无需等待代理处理完毕。
  • 出站队列 outbound:负责传递代理的最终回复。处理完成的消息结果会被 put 到此队列,由专门的输出协程取走并打印。

这一设计实现了经典的 生产者-消费者模式:用户输入是生产者,run 方法是消费者,output 方法是另一个消费者。三者通过队列通信,互不阻塞。

我们将上述逻辑封装成 AsyncSubagent 类:

class AsyncSubagent:
    def __init__(self):
        # 入站异步队列
        self.inbound = asyncio.Queue()
        # 出站异步队列
        self.outbound = asyncio.Queue()
        self._running = False
        self.history = [{"role": "system", "content": SYSTEM}]

    async def agent_loop(self, user_msg: str) -> str:
        self.history.append({"role": "user", "content": user_msg})
        
        while True:
            response = await client.chat.completions.create(
                model=MODEL,
                messages=self.history,
                tools=tools,
                tool_choice="auto"
            )
            msg = response.choices[0].message
            
            # 添加助手的回复
            self.history.append(msg)
            
            # 如果模型没有调用工具,说明已完成任务
            if not msg.tool_calls:
                return msg.content
            # 否则,执行每个工具调用并收集结果
            for tool_call in msg.tool_calls:
                if tool_call.function.name == "task":
                    args = json.loads(tool_call.function.arguments)
                    desc = args.get("description", "子任务")
                    print(f"\033[33m🧩 task ({desc}): {args['prompt'][:80]}\033[0m")
                    result = await run_subagent(args["prompt"])
                    print(f"✅ 子代理摘要:\n{result[:200]}\n")
                else:
                    result = f"未知工具: {tool_call.function.name}"
                print(f"✅ 执行父代理{str(result)[:200]}\n")
                self.history.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": str(result)
                })

    # ------------------------------------------------------------------
    # 主循环:持续消费 入站异步队列
    # ------------------------------------------------------------------
    async def run(self) -> None:
        """运行智能体循环,处理来自总线的消息。"""
        self._running = True
        print("智能体循环已启动")

        while self._running:
            try:
                # 从入站队列消费下一条消息,设置超时以便能定期检查 _running 标志
                msg = await asyncio.wait_for(
                    self.inbound.get(),
                    timeout=1.0,
                )
                try:
                    # 处理消息并获取响应
                    response = await self.agent_loop(msg)
                    print(f'消息:{response}')
                    if response:
                        # 将响应发布到出站队列
                        await self.outbound.put(response)
                except Exception as e:
                    print(f"处理消息时出错: {e}")
                    await self.outbound.put(
                        f"抱歉,处理消息时出错:{e}"
                    )
            except asyncio.TimeoutError:
                continue

    def stop(self) -> None:
        """停止智能体循环。"""
        self._running = False

    async def output(self):
        print('打印输出')
        while self._running:
            try:
              # 从出站队列消费下一条消息,设置超时以便能定期检查 _running 标志
                final_answer = await asyncio.wait_for(
                    self.outbound.get(),
                    timeout=1.0,
                )
                if final_answer:
                    print(f"\n\033[32m助手: {final_answer}\033[0m")
                    print("\033[36m用户 >> \033[0m", end="", flush=True)
            except asyncio.TimeoutError:
                continue

run 方法永远从 inbound 队列中取消息并调用 agent_loop 处理;output 方法永远从 outbound 队列中取结果并打印到终端。两者通过 asyncio.wait_for 设置 1 秒超时,以便定期检查 _running 标志并实现优雅停止。

3.3 三大协程并发:事件循环的调度

在 main 函数中,我们同时运行三个独立的协程:

async def main():
    agent = AsyncSubagent()
    task = asyncio.create_task(agent.run())        # 处理协程
    output_task = asyncio.create_task(agent.output())  # 输出协程
    while True:
        query = await asyncio.to_thread(input, "\033[36m用户 >> \033[0m")
        if query.strip().lower() in ("q", "exit", ""):
            break
        await agent.inbound.put(query)
    agent.stop()
    await task
    await output_task

if __name__ == "__main__":
    asyncio.run(main())
  • 输入协程:通过 asyncio.to_thread 将阻塞的 input() 放到线程池中执行,避免阻塞事件循环。获得输入后放入 inbound 队列。
  • 处理协程:即 run(),不断消费 inbound 队列,调用 agent_loop 处理每条消息。
  • 输出协程:即 output(),不断消费 outbound 队列,打印最终答案。

三个协程在同一个事件循环中并发运行。当处理协程遇到 await(例如等待 LLM 响应或子代理完成)时,事件循环会切换到输入协程或输出协程,从而实现“一边处理旧任务,一边接收新输入”的效果。

这种架构与前面章节提到的 MiniOpenClaw 实现思路类似——都依赖消息队列解耦各环节,利用异步 I/O 最大化资源利用率。

3.4 小结

异步 Subagent 通过 双队列 + 协程并发 彻底解决了同步模型的阻塞问题。其核心设计原理可概括为:

  • 用 AsyncOpenAI 和 await 将阻塞 I/O 变为非阻塞等待。
  • 用入站/出站队列解耦输入、处理、输出三个环节,实现生产者-消费者模式。
  • 用事件循环并发运行多个协程,确保子代理执行期间系统仍可响应用户。
  • 用带超时的 wait_for 实现协作式优雅停止。

这一架构为构建响应式、可伸缩的 Agent 系统奠定了坚实基础。下一节,我们将在此架构上引入插件化机制,让子代理的定义从硬编码变为配置文件驱动,实现真正的动态扩展。

4. 插件化机制:从硬编码到配置驱动

在前两节中,子代理的配置(名称、描述、系统提示词、可用工具)都是硬编码在代码里的。每增加一个新类型的子代理,就需要修改主程序、重新定义工具、调整路由逻辑——这显然不满足可扩展性要求。Claude Code 的 Subagents 设计给了我们启发:将子代理的定义与实现分离,用配置文件来描述

Claude Code 的子代理定义本质上是一组标准化的 Markdown 文件,采用“元数据 + 指令”的模块化方式,存放在 .claude/agents/ 目录下。每个配置文件包含:

  • name:子代理的唯一标识
  • description:供主代理路由时使用的自然语言描述(主代理根据此判断调用哪个子代理)
  • system_prompt:该子代理的系统提示词(Markdown 正文)
  • tools:允许该子代理调用的工具白名单
  • model:可选,指定使用哪个 LLM 模型(例如快速模型 vs 强大模型)

具体示例如下:

---
name: security-reviewer
description: 审查代码安全漏洞,检查 SQL 注入、XSS、权限绕过等风险
tools: read_file, grep
model: deepseek-coder
---
你是一名资深安全专家。当收到代码文件路径时,使用 read_file 读取内容,
重点关注敏感输入处理、权限校验、加密存储等环节。输出需包含风险等级和建议修复方案。

这种配置即代码的方式极大提升了系统的可维护性:新增子代理只需添加一个 .md 文件,无需修改主程序。本节我们将实现这样一个插件化 Subagent 系统。

4.1 为什么需要插件化?

回顾之前的实现,每次要增加一个子代理(比如代码审查员、测试生成器、文档整理员),我们都需要:

  1. 手动编写该子代理的 system_prompt
  2. 在主代理的工具列表 tools 中添加对应的工具定义。
  3. 在 agent_loop 的工具路由分支中增加 elif 逻辑。
  4. 重新启动程序。

这种操作对于开发环境也许可以忍受,但对于需要频繁调整或多人协作的生产系统而言是不可接受的。插件化的目标就是:将子代理的注册、配置、启用都变成数据驱动,让系统具有“热插拔”能力。

4.2 配置文件解析:复用 Agent Skills 的设计

由于 Subagent 配置文件的格式与 Agent Skills 几乎一致(frontmatter + 正文),我们可以复用之前的逻辑实现 load_agents 函数。该函数扫描指定目录(默认为 ./agents)下的所有 .md 文件,解析出元数据和系统提示词,返回一个子代理配置列表。

import os
import json
import re
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI

# 加载环境变量(如 DEEPSEEK_API_KEY)
load_dotenv()

def load_agents(agents_dir="./agents"):
    agents = []
    agents_path = Path(agents_dir)
    if not agents_path.exists():
        return agents
    
    for file_path in agents_path.glob("*.md"):
        content = file_path.read_text(encoding="utf-8")
        # 匹配 --- 包裹的 frontmatter 和后面的正文
        match = re.match(r"^---\n(.*?)\n---\n(.*)$", content, re.DOTALL)
        if match:
            frontmatter = match.group(1)
            body = match.group(2).strip()
            
            # 简易解析 YAML 风格键值对
            metadata = {}
            for line in frontmatter.split('\n'):
                if ':' in line:
                    key, val = line.split(':', 1)
                    val = val.split('#')[0].strip()
                    metadata[key.strip()] = val
                    
            agents.append({
                "name": metadata.get("name", file_path.stem),
                "description": metadata.get("description", "A subagent"),
                "tools": [t.strip() for t in metadata.get("tools", "").split(",") if t.strip()],
                "model": metadata.get("model", "deepseek-chat"),
                "system_prompt": body
            })
    return agents

为什么这样解析?

  • 使用 re.match 匹配 frontmatter 与正文的分隔符 ---,这是大多数静态网站生成器和配置文件的通用格式,简单可靠。
  • 手动解析键值对,避免引入额外的 YAML 依赖库,保持轻量。
  • 对 tools 字段按逗号分割并去除空白,支持 Read, Grep, Glob 或 read_file, grep 等风格。

4.3 动态生成工具:将每个子代理包装为一个 function call

加载完所有子代理配置后,我们需要为主代理生成对应的工具定义。目标是让主代理可以通过自然语言调用子代理,例如主代理说“请安全审查一下 app.py”,LLM 应该能够自动选择 security-reviewer 子代理。

我们为每个加载的子代理创建一个工具,工具名称为 call_{agent_name}(将名称中的连字符替换为下划线,以符合函数命名规范)。工具的描述直接取自配置文件中的 description,这是主代理 LLM 判断是否调用该子代理的关键信息。

# ---------- 工具定义 ----------
loaded_agents = load_agents()

tools = []
for agent in loaded_agents:
    tools.append({
        "type": "function",
        "function": {
            "name": f"call_{agent['name'].replace('-', '_')}",
            "description": agent['description'],
            "parameters": {
                "type": "object",
                "properties": {
                    "prompt": {"type": "string", "description": "分配给该子代理的具体任务要求和上下文"}
                },
                "required": ["prompt"]
            }
        }
    })

# 默认后备工具
if not tools:
    tools = [
        {
            "type": "function",
            "function": {
                "name": "task",
                "description": "以全新上下文派生一个子代理。子代理共享文件系统,但不共享对话历史。",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "prompt": {"type": "string", "description": "子任务提示词"},
                        "description": {"type": "string", "description": "任务的简短描述"}
                    },
                    "required": ["prompt"]
                }
            }
        }
    ]

child_tools = [
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "读取文本文件内容。",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "要读取的文件路径"},
                    "encoding": {"type": "string", "enum": ["utf-8", "gbk"], "description": "文件编码格式"}
                },
                "required": ["path"]
            }
        }
    }
]

如果没有任何子代理配置文件(例如目录为空),我们保留一个后备的通用 task 工具,保证基础功能可用。

为什么每个子代理对应一个独立的工具?
因为 LLM 是根据工具的 description 来决定是否调用的。每个子代理的描述应该突出其专业领域,例如“审查代码安全漏洞”会引导 LLM 在处理安全问题时调用该工具。如果只用一个通用的 task 工具,LLM 就失去了精细路由的能力。

4.4 子代理的工具权限隔离

插件化不仅要解决“怎么调用”,还要解决“能做什么”。配置文件中的 tools 字段定义了该子代理可以使用的工具白名单。在 run_subagent 函数中,我们需要根据这个白名单过滤子代理可用的工具列表。

# 工具映射
AVAILABLE_CHILD_TOOLS = {
    "Read": child_tools[0],  # 映射到 read_file
    "read_file": child_tools[0],
}

# ---------- 工具实现 ----------
class ReadFileTool:
    def execute(self, path: str, encoding: str = "utf-8") -> str:
        try:
            file_path = Path(path).expanduser()
            if not file_path.exists():
                return f"❌ 文件不存在: {path}"
            return file_path.read_text(encoding=encoding)
        except Exception as e:
            return f"❌ 读取失败: {str(e)}"

file_tool = ReadFileTool()

# ---------- 初始化客户端 ----------
client = OpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com"
)

SYSTEM = f"你是主代理,你可以调用工具将任务分配给不同的专业子代理来协助用户。请根据用户的需求选择合适的子代理。"
SUBAGENT_SYSTEM = f"你是文件助手的编码子代理。完成给定任务后,总结你的发现。"
MODEL = "deepseek-chat"

def run_subagent(agent_config: dict, prompt: str) -> str:
    system_prompt = agent_config.get("system_prompt", SUBAGENT_SYSTEM)
    model = agent_config.get("model", MODEL)
    
    # 根据配置过滤工具
    agent_tools_names = [t.lower() for t in agent_config.get("tools", [])]
    allowed_tools = []
    for tool_name in agent_tools_names:
        # 简单映射:read -> read_file
        if tool_name == "read" or tool_name == "read_file":
            allowed_tools.append(AVAILABLE_CHILD_TOOLS["read_file"])
            
    # 如果没有指定工具,或者匹配不到,可以给默认工具或者为空
    if not allowed_tools:
        allowed_tools = child_tools

    sub_messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": prompt}
    ]
    for _ in range(30):
        response = client.chat.completions.create(
            model=model,
            messages=sub_messages,
            tools=allowed_tools if allowed_tools else None,
            tool_choice="auto" if allowed_tools else None
        )
        msg = response.choices[0].message
        sub_messages.append(msg)
        if not msg.tool_calls:
            return msg.content or "(无摘要)"
        for tool_call in msg.tool_calls:
            if tool_call.function.name == "read_file":
                args = json.loads(tool_call.function.arguments)
                result = file_tool.execute(**args)
                sub_messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "name": tool_call.function.name,
                    "content": result
                })
    return "(无摘要)"

这里的关键设计是 工具映射层:配置文件中的工具名(如 readgrepglob)需要映射到代码中实际的函数定义。本例中我们只实现了 read_file,因此只映射了 read 和 read_file。在完整实现中,可以维护一个全局工具注册表,根据名称动态获取工具定义。

为什么要做权限隔离?
如果让一个代码审查子代理也能写入文件或执行系统命令,可能会带来安全风险。最小权限原则要求每个子代理只能访问完成任务所必需的工具。配置文件的白名单机制让这一约束变得显式且易于管理。

4.5 主代理的路由逻辑

主代理的 agent_loop 现在需要处理两类工具调用:

  • call_{agent_name} 系列工具:动态生成的子代理调用工具。
  • 后备的 task 工具:兼容无配置文件时的通用子代理。
def agent_loop(messages: list):
    while True:
        response = client.chat.completions.create(
            model="deepseek-chat",
            messages=messages,
            tools=tools if tools else None,
            tool_choice="auto" if tools else None
        )
        msg = response.choices[0].message
        
        # 添加助手的回复
        messages.append(msg)
        
        # 如果模型没有调用工具,说明已完成任务
        if not msg.tool_calls:
            return msg.content
        # 否则,执行每个工具调用并收集结果
        for tool_call in msg.tool_calls:
            func_name = tool_call.function.name
            args = json.loads(tool_call.function.arguments)
            
            # 判断是否是调用子代理工具
            if func_name.startswith("call_"):
                agent_name_from_func = func_name[5:].replace("_", "-")
                agent_config = next((a for a in loaded_agents if a['name'].replace("-", "_") == func_name[5:]), None)
                
                if agent_config:
                    print(f"\033[33m🧩 启动子代理 ({agent_config['name']}): {args['prompt'][:80]}...\033[0m")
                    result = run_subagent(agent_config, args["prompt"])
                    print(f"✅ 子代理 {agent_config['name']} 返回结果:\n{result[:200]}...\n")
                else:
                    result = f"找不到子代理: {agent_name_from_func}"
            elif func_name == "task":
                desc = args.get("description", "子任务")
                print(f"\033[33m🧩 task ({desc}): {args['prompt'][:80]}\033[0m")
                result = run_subagent({"name": "default"}, args["prompt"])
                print(f"✅ 子代理摘要:\n{result[:200]}\n")
            else:
                result = f"未知工具: {func_name}"
            print(f"  {str(result)[:200]}")
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": str(result)
            })

这段代码的核心是 动态路由:根据工具名的前缀识别子代理调用,然后从配置列表中查找对应的代理配置。注意名称的归一化处理:配置文件中的 name 可能包含连字符(如 security-reviewer),而工具名中的连字符被替换为下划线(call_security_reviewer),我们需要在查找时双向转换。

为什么不在工具定义时直接使用原始名称?
因为 OpenAI 的函数名要求符合标识符规范(字母、数字、下划线,不能有连字符),所以我们使用下划线;但在匹配配置时,为了与文件名保持一致,需要将下划线转回连字符。

4.6 插件化带来的好处

实现上述插件化机制后,系统获得了以下能力:

  • 零代码扩展:新增一个子代理只需编写一个 .md 文件,重启程序即可生效。
  • 清晰的职责描述:每个子代理的 description 直接作为主代理的决策依据,LLM 可以根据自然语言描述选择最合适的子代理。
  • 独立的工具权限:每个子代理只能使用其白名单内的工具,安全可控。
  • 可选的模型配置:可以为不同子代理指定不同的模型(例如快速任务用 deepseek-chat,复杂推理用 deepseek-coder),实现成本与效果的平衡。
  • 与 Agent Skills 体系一致:解析机制与 Skills 复用,降低学习成本。

4.7 对比三种实现模式的演进

回顾全文,我们经历了三个阶段的演进:

模式上下文隔离非阻塞插件化适用场景
基础同步 Subagent简单脚本、单用户工具
异步 Subagent需要响应用户交互的应用
插件化 Subagent(可叠加异步)需要频繁扩展能力的生产系统

插件化可以与异步架构无缝结合:只需将 run_subagent 改为异步版本,并将 agent_loop 中的调用改为 await,即可同时拥有非阻塞和动态加载能力。这种组合是生产级 AI Agent 系统的推荐方案。

4.8 小结

本节我们实现了一个与 Claude Code 类似的插件化 Subagent 系统,核心设计原理包括:

  • 配置驱动:使用 Markdown + frontmatter 定义子代理,通过扫描目录动态加载。
  • 动态工具生成:为每个子代理创建独立的 function call 工具,让 LLM 能够根据描述自动路由。
  • 工具权限隔离:根据配置文件的 tools 白名单过滤子代理可用的工具,遵循最小权限原则。
  • 灵活的路由逻辑:在 agent_loop 中根据工具名前缀动态查找配置并调用对应的子代理。

这种设计使得 Subagent 系统从“硬编码的辅助函数”进化为“可插拔的能力单元”,为构建复杂、可扩展的 AI Agent 生态奠定了坚实基础。

5. 总结

所谓 Subagent 模式本质就是通过上下文隔离解决了单体 Agent 的历史膨胀问题。

我们从同步单层委托起步,引入 task 工具实现基础隔离;进而借助异步队列和事件循环,实现非阻塞的消息处理,提升系统响应性;最后通过配置文件驱动的插件化机制,将子代理定义与实现解耦,实现零代码扩展与权限隔离。三者层层递进,共同构建了可扩展、高响应的 AI Agent 协作体系。

我是程序员Cobyte,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。