1. 前言:什么是 Subagent ?
在前文讲解 Agent Skills 时我们提到,通过编写提示词驱动大模型工作会产生大量重复内容,为此设计了按需加载的 Skills 系统以降低 Token 消耗。然而即便如此,大模型在多轮对话后,其上下文窗口依然被不断膨胀的对话历史与中间结果挤占,导致响应变慢、事实遗忘、指令遵循能力下降。
因此 AI Agent 需要从“单兵作战”走向“协同系统”,Subagent(子代理) 模式应运而生。
Subagent(子代理) 是一种设计模式:主代理(Orchestrator)负责理解用户意图,将特定任务“委托”给一个全新上下文的子代理去执行,子代理完成后只返回一个简洁的摘要。
这种模式的好处显而易见:
- 上下文隔离:子代理的工作不会污染主对话历史,避免无关信息干扰。
- 专注单一职责:每个子代理可以针对特定任务优化系统提示和工具集。
- 可复用与扩展:可以像插件一样增加新的子代理,主代理无需修改核心逻辑。
本文将通过三段循序渐进的代码示例,深入剖析 Subagent 的实现原理——从最基础的同步单层委托,到异步消息驱动的并发架构,再到基于配置文件的动态插件系统。
2. Subagent 的核心设计理念
2.1 工具调用基础:大模型如何调用函数
要理解 Subagent 的实现原理,必须吃透 OpenAI 风格的工具调用(Function Calling)是如何与代理循环结合的。大模型调用工具的标准流程包含以下 7 个步骤:
- 定义工具:用 JSON Schema 描述函数名称、功能、参数。
- 附加到请求:将工具定义随用户消息一起发送给模型。
- 模型判断:模型根据用户输入判断是否需要调用工具,以及调用哪个工具和参数。
- 解析响应:从模型返回的
tool_calls字段中提取工具名称和参数。 - 执行本地函数:在代码中调用对应的函数,获得执行结果。
- 回填结果:将结果封装为
tool角色的消息,追加到对话历史中。 - 模型生成最终答案:模型结合工具结果生成面向用户的回答。
以下是一个简单的 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:- 调用 LLM,根据对话历史决定是否需要调用工具。
- 如果模型返回
tool_calls,则逐个执行工具。 - 遇到
task工具时,调用run_subagent(prompt)函数。 - 将子代理返回的摘要作为
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 的 name 和description 字段描述了子代理的能力,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 为什么需要插件化?
回顾之前的实现,每次要增加一个子代理(比如代码审查员、测试生成器、文档整理员),我们都需要:
- 手动编写该子代理的
system_prompt。 - 在主代理的工具列表
tools中添加对应的工具定义。 - 在
agent_loop的工具路由分支中增加elif逻辑。 - 重新启动程序。
这种操作对于开发环境也许可以忍受,但对于需要频繁调整或多人协作的生产系统而言是不可接受的。插件化的目标就是:将子代理的注册、配置、启用都变成数据驱动,让系统具有“热插拔”能力。
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 "(无摘要)"
这里的关键设计是 工具映射层:配置文件中的工具名(如 read、grep、glob)需要映射到代码中实际的函数定义。本例中我们只实现了 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 应用开发。