深入理解 Subagent 的实现原理

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 实现,完整地展现了这一过程:

python 复制代码
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 中。

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

diff 复制代码
# 省略...

# ---------- 工具定义 ----------
+ 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 文件,统计英文和中文各自的文字总数,以及中文和英文的文字总数是多少?" 子代理将独立完成文件读取和统计,主代理最终输出结果。

结果输出如下:

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

diff 复制代码
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 类:

python 复制代码
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 函数中,我们同时运行三个独立的协程:

python 复制代码
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 通过 双队列 + 协程并发 彻底解决了同步模型的阻塞问题。其核心设计原理可概括为:

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

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

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

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

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

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

具体示例如下:

yaml 复制代码
---
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 文件,解析出元数据和系统提示词,返回一个子代理配置列表。

python 复制代码
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, Globread_file, grep 等风格。

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

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

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

python 复制代码
# ---------- 工具定义 ----------
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 函数中,我们需要根据这个白名单过滤子代理可用的工具列表。

python 复制代码
# 工具映射
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,因此只映射了 readread_file。在完整实现中,可以维护一个全局工具注册表,根据名称动态获取工具定义。

为什么要做权限隔离?

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

4.5 主代理的路由逻辑

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

  • call_{agent_name} 系列工具:动态生成的子代理调用工具。
  • 后备的 task 工具:兼容无配置文件时的通用子代理。
python 复制代码
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 应用开发。

相关推荐
qq_25183645713 分钟前
AI 疾病自查功能SpringbootAI项目实战 · 技术实现文档
java·ai·ai编程
狗头大军之江苏分军18 分钟前
《潮水漫过十七岁》开学了
后端
苏三说技术1 小时前
如何看待GPT-6在UP主众测中碾压夺冠?它是现在最强大模型吗?
后端
mldong1 小时前
一份 JSON,一条能跑的审批流:把报销流程送上工作流引擎
后端·架构
wno7041 小时前
Spring Boot WebFlux增删改查
java·spring boot·后端
Captaincc1 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
aramae2 小时前
模拟实现strlen()函数 (C语言)
c语言·开发语言·后端
计算机魔术师3 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen3 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒3 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端