对应代码路径:
src/qwenpaw/agents/
一、整体架构

继承层次 (MRO)
scss
QwenPawAgent
└── CodingModeMixin (编码模式: 注入编码提示词、注册 LSP/AST 工具)
└── ToolGuardMixin (工具安全守卫: 拦截敏感工具调用 + 审批流程)
└── ReActAgent (AgentScope 框架的 ReAct 循环引擎)
└── AgentBase (AgentScope 基类)
这个继承层次是理解整个 Agent 行为的关键。 从底向上看,AgentBase 是 AgentScope 框架提供的最底层基类,定义了 Agent 的最基本契约------能接收消息、能回复消息。ReActAgent 在基类之上实现了完整的 ReAct(Reasoning + Acting)循环引擎:它是一个 while 循环,每次迭代先让 LLM "思考"(_reasoning),如果 LLM 返回了工具调用请求,就执行工具(_acting),然后把工具结果放回上下文,继续下一轮推理。这个循环一直持续到 LLM 决定直接回复文本,或者达到最大迭代次数。
ToolGuardMixin 和 CodingModeMixin 是两个 Mixin 类,它们的唯一目的就是在 _acting 这个方法上插入拦截逻辑 。ToolGuardMixin 负责安全检查------每次 LLM 想调用工具时,它先检查这个工具有没有被禁用、是否越界、是否含注入代码、需不需要人工审批。CodingModeMixin 在编码模式下注册 IDE 类工具(LSP 语言服务、AST 搜索),让 Agent 能理解代码结构、跳转定义、查找引用。
顶层的 QwenPawAgent 是实际使用的 Agent 类,它把所有能力组装到一起:工具注册、技能加载、提示词构建、模型创建、钩子注册、命令处理。三个层次各司其职,互不干扰。
为什么用 Mixin 而不是组合? 因为
_acting是ReActAgent的核心方法,ReAct 循环中每次工具调用都会调用它。Mixin 方式通过 Python 的 MRO(方法解析顺序,即 C3 线性化算法)让子类 override 这个方法后调用super()._acting()形成处理链------每个 Mixin 只处理自己关心的事,然后透传给下一个。组合模式需要在外部写一个包装类显式转发每个方法,做不到这种对核心循环的透明拦截。这里选 Mixin 不是图方便,而是因为拦截点就这一个方法,Mixin 是表达这种"洋葱中间件"语义最自然的 Python 方式。
模块文件一览
| 文件/目录 | 作用 |
|---|---|
react_agent.py (1477行) |
核心 Agent 类 --- 初始化、工具注册、技能加载、系统提示词、ReAct 重写 |
coding_mode_mixin.py |
编码模式 Mixin --- 注入编码提示词、注册 LSP/AST 工具 |
tool_guard_mixin.py (810行) |
安全守卫 Mixin --- 工具调用拦截、审批流程、自动拒绝 |
command_handler.py (748行) |
系统命令处理 --- /compact, /new, /clear, /summary 等 |
model_factory.py (1075行) |
模型工厂 --- 创建模型实例 + Formatter |
prompt.py |
提示词构建 --- 从 AGENTS.md / SOUL.md / PROFILE.md 加载 |
skill_system/ |
技能系统 --- 技能注册、池管理、工作区同步 |
hooks/ |
钩子系统 --- 包含 BootstrapHook(首次交互引导) |
memory/ |
记忆系统 --- 向量检索记忆、文件记忆、PG 记忆 |
context/ |
上下文管理 --- 轻量级上下文管理器(智能截断) |
tools/ |
内置工具实现 --- 文件、Shell、浏览器、桌面截图等 |
skills/ |
内置技能定义 --- PDF、DOCX、新闻等(每个技能一个目录) |
utils/ |
工具函数 --- 文件处理、Token 计数、消息处理 |
二、Agent 初始化流程 (init)
Agent 的初始化是整个系统中逻辑最密集的阶段之一。它不只是 new 一个对象那么简单------在构造函数执行完毕时,Agent 已经完成了工具注册、技能加载、提示词构建、模型创建、记忆系统对接、命令解析、钩子挂载等一整套准备工作。理解这个初始化流程,就理解了 Agent 所有能力的来源。
当 AgentRunner 创建 QwenPawAgent 时,__init__ 按顺序执行以下步骤。这个顺序不是随意的------每一步都依赖于前面步骤产生的对象(例如:只有先解析出可用技能列表,才能注册技能对应的工具;只有先构建好 toolkit,才能初始化 ReActAgent 父类):
scss
QwenPawAgent.__init__()
│
├── 1. 保存基础属性
│ ├── _agent_config → Agent 配置对象
│ ├── _env_context → 环境上下文文本
│ ├── _request_context → 请求元数据 (session_id, user_id, channel, agent_id)
│ ├── _mcp_clients → MCP 客户端列表
│ ├── _workspace_dir → 工作区目录
│ └── _task_tracker → 任务追踪器
│
├── 2. 解析适用技能
│ ├── ensure_skills_initialized(workspace_dir) → 确保技能目录就绪
│ ├── resolve_effective_skills(workspace_dir, channel)
│ │ → 根据 channel 筛选启用的技能列表
│ └── 例如: console 渠道可能启用所有技能,telegram 渠道只启用部分
│
├── 3. 创建工具注册表 (Toolkit)
│ └── _create_toolkit(namesake_strategy, effective_skills)
│ ├── 添加所有内置工具 (file_io, shell, browser, 等)
│ ├── 检查 agent_config.tools.builtin_tools 配置
│ │ └── 根据 enabled=True/False 决定每个工具是否注册
│ ├── 设置 async_execution 标志 (shell / delegate 支持后台异步)
│ ├── 注册 MCP 客户端提供的工具
│ └── 如果 coding_mode 启用: 注册 LSP + AST 工具
│
├── 4. 注册技能
│ └── _register_skills(toolkit, effective_skills)
│ └── 对每个技能: toolkit.register_agent_skill(skill_dir)
│ → 读取 SKILL.md, 将技能描述注入 system prompt
│
├── 5. 构建系统提示词
│ └── _build_sys_prompt()
│ ├── PromptBuilder(working_dir, heartbeat_enabled, language, memory_manager)
│ │ ├── 加载 AGENTS.md → Agent 身份描述
│ │ ├── 加载 SOUL.md → Agent 性格/行为准则
│ │ └── 加载 PROFILE.md → 用户偏好/背景信息
│ ├── 附加多模态能力提示 (model reject 媒体块提示)
│ ├── 附加环境上下文 (_env_context)
│ └── 如果 coding_mode 启用: 附加编码模式提示词模板
│
├── 6. 创建模型和格式化器
│ └── create_model_and_formatter(agent_id)
│ ├── 查询 agent 配置中的 active_model
│ ├── 从 providers 管理器获取对应的模型类
│ └── 封装 FileBlockSupportFormatter (支持文件/媒体块)
│
├── 7. 初始化 ReActAgent 父类
│ └── super().__init__(name, model, sys_prompt, toolkit, memory=InMemoryMemory(),
│ formatter, max_iters, plan_notebook)
│
├── 8. 注册记忆系统工具
│ └── memory_manager.list_memory_tools() → 注册到 toolkit
│ (如: 检索记忆、存储记忆等工具函数)
│
├── 9. 配置上下文管理器
│ └── context_manager.get_agent_context() → 替换 self.memory 为 AgentContext
│ (AgentContext 包装了记忆操作,支持智能截断)
│
├── 10. 初始化命令处理器
│ └── CommandHandler(agent_name, memory, memory_manager, context_manager)
│
└── 11. 注册钩子
└── _register_hooks()
├── BootstrapHook → pre_reasoning 钩子 (首次交互引导)
└── 上下文管理器钩子 → pre_reply / pre_reasoning / post_acting / post_reply
三、ReAct 循环机制
这是 Agent 的核心------它决定了 Agent 如何理解用户问题、决定需要做什么、执行工具、再从结果中继续推理,最终给出完整答案。这个循环本质上模拟了人类解决问题的思考过程:先想(Reasoning),再做(Acting),观察结果,继续想,直到问题解决。
当 AgentRunner 调用 agent.reply(msg) 时,整个生命周期如下展开。这里有一个容易被忽略的设计细节:系统命令的检查发生在 ReAct 循环之外 。这意味着 /compact、/new 等命令可以瞬间执行,不需要经过 LLM 推理,既快又不消耗 Token。这也是为什么命令响应比普通对话快得多的原因。
erlang
reply(msg)
│
├── 1. 设置线程本地上下文 (workspace_dir, session_id, timeout 等)
│
├── 2. 处理文件/媒体块
│ └── process_file_and_media_blocks_in_message(msg)
│ → 将文件路径转为文件内容/媒体 URI
│
├── 3. 检查系统命令
│ └── command_handler.is_command(query)
│ ├── "/compact" → 压缩记忆
│ ├── "/new" → 开始新对话
│ ├── "/clear" → 清除记忆
│ ├── "/history" → 查看历史
│ └── "/plan ..." → 启动计划模式
│ ← 是命令 → 直接处理并返回,不走 ReAct
│
└── 4. 进入 ReAct 循环 (super().reply())
│
├── ▶ 循环开始
│ │
│ ├── (A) 钩子: pre_reasoning
│ │ ├── BootstrapHook → 首次交互引导
│ │ └── ContextManager.pre_reasoning → 上下文截断
│ │
│ ├── (B) _reasoning(tool_choice)
│ │ 调用 LLM 模型推理
│ │ ├── 主动层: 若模型不支持多模态,提前剥离媒体块
│ │ ├── 调用 LLM → 返回 assistant_msg
│ │ └── 被动层: 若 LLM 报错(媒体错误),剥离媒体块重试
│ │ └── 自动继续: 若 assistant 只返回文字(没调工具),可自动再调一次
│ │
│ ├── (C) 检查是否有 tool_call
│ │ ├── 没有 → 结束循环,返回最终回答
│ │ └── 有 tool_call → 进入工具执行
│ │
│ ├── (D) _acting(tool_call) ← 关键拦截点
│ │ ├── [QwenPawAgent._acting]
│ │ │ ├── 检查 plan 门控 (计划突变后限制工具)
│ │ │ └── 修复 JSON 参数 (某些模型返回 stringified JSON)
│ │ │
│ │ ├── [ToolGuardMixin._acting] ← 安全管理器
│ │ │ ├── 获取执行级别 (OFF/AUTO/SMART/STRICT)
│ │ │ ├── 检查 denied_tools → 命中则自动拒绝
│ │ │ ├── 运行守卫引擎 (所有已注册的 guardian)
│ │ │ │ ├── file_guardian → 检查文件路径
│ │ │ │ ├── shell_evasion_guardian → 检查命令注入
│ │ │ │ └── approval_guardian → 需要人工审批
│ │ │ ├── 根据结果决定: 自动允许 / 自动拒绝 / 需要审批
│ │ │ └── 需要审批时 → 等待用户确认 (前端弹窗/渠道交互)
│ │ │
│ │ └── [ReActAgent._acting] ← 实际工具执行
│ │ └── 执行工具函数 → 返回 tool_result
│ │
│ ├── (E) 钩子: post_acting
│ │ └── ContextManager.post_acting → 处理工具结果(截断等)
│ │
│ └── (F) 回到循环开始 (最多 max_iters 次)
│
├── (G) _summarizing() (可选)
│ 若需要总结 (达到 compact 阈值) → 压缩历史
│
├── (H) 钩子: post_reply
│ └── ContextManager.post_reply
│
└── (I) 返回最终回复 Msg
关键点:_acting 调用链的依赖顺序
QwenPawAgent._acting() → ToolGuardMixin._acting() → ReActAgent._acting()
每个 _acting 方法内部都调用了 super()._acting(tool_call),通过 Python 的 MRO (C3 线性化) 形成调用链。这是 Mixin 模式的核心优势------透明拦截。
scss
QwenPawAgent._acting(tool_call)
├── Plan gate check
└── super()._acting() → ToolGuardMixin._acting()
├── Safety check (denied/guarded/approval)
└── super()._acting() → ReActAgent._acting()
└── === 真实工具执行 ===
四、工具系统详解
工具系统是 Agent 与外界交互的唯一通道。LLM 本身是一个"纯文本大脑"------它无法直接读取文件、执行命令或浏览网页。工具系统扮演了"手和眼"的角色:LLM 决定"做什么",工具系统负责"怎么做"。因此,工具系统的设计直接决定了 Agent 的能力边界和安全性。
4.1 工具注册流程
_create_toolkit() 是工具注册的核心方法,它在初始化阶段被调用一次,完成所有工具的定义和注册。以下流程图展示的每个工具注册步骤都对应一个副作用------在 System Prompt 中追加该工具的 JSON Schema 描述。这意味着:注册的工具越多,System Prompt 越长,LLM 的选择空间也越大,但同时 Token 消耗也越高 。这就是为什么 builtin_tools 配置提供了按需禁用特定工具的能力------这是一个 Token 成本与能力覆盖范围之间的权衡。
scss
QwenPawAgent._create_toolkit()
│
├── 创建 Toolkit 实例
│
├── 循环注册内置工具 (每个工具一个 register_tool_function 调用)
│ ├── execute_shell_command (async_execution=可选)
│ ├── execute_python_code
│ ├── read_file / write_file / edit_file / append_file
│ ├── grep_search / glob_search
│ ├── browser_use (async_execution=True)
│ ├── desktop_screenshot
│ ├── view_image / view_video
│ ├── get_current_time / set_user_timezone
│ ├── send_file_to_user
│ ├── list_agents / chat_with_agent / submit_to_agent / check_agent_task
│ ├── delegate_external_agent (async_execution=可选)
│ └── materialize_skill (始终启用)
│
├── 根据 agent_config.tools.builtin_tools 过滤
│ └── 检查每个工具的 enabled 标志
│ └── disabled → 从 toolkit 中移除 (unregister)
│
├── 注册 MCP 工具
│ └── 遍历 _mcp_clients, 将每个 MCP client 的 tools 注册进来
│
└── [CodingModeMixin] 注册额外工具
└── 若 coding_mode 启用:
├── make_lsp_tool(available_languages) → LSP 语言服务工具
└── ast_search() → AST 语法树搜索工具
4.2 namesake_strategy(工具重名处理)
当不同来源的工具出现同名时,需要一个策略来决定谁"赢"。这个问题的典型场景是:MCP 客户端注册了一个叫 read_file 的工具,而内置工具也有 read_file------二者同名工具名冲突在多来源工具注册的场景下几乎是必然发生的。
| 策略 | 行为 | 使用场景 |
|---|---|---|
"skip" (默认) |
跳过后续注册的同名工具,保留最先注册的 | 保守策略,内置工具优先 |
"override" |
最新注册的覆盖旧的 | MCP 工具优先于内置 |
"raise" |
抛出异常 | 研发环境,发现重复立刻暴露问题 |
"rename" |
自动重命名(加 _1, _2 后缀) |
两者都想保留,让 LLM 自行选择 |
4.3 工具执行安全守卫 (ToolGuardMixin)
安全守卫是 Agent 安全体系的核心防线。它的设计思路是:LLM 可能被 prompt injection 攻击骗去执行危险操作,所以不能信任 LLM 发出的任何工具调用,必须在执行前逐一检查。这里的核心矛盾是安全与效率------每次工具调用都要过一遍守卫检查,如果每个检查都让用户确认,用起来会非常烦琐。解决方案是引入"执行级别"概念:用户可以根据场景选择宽松(AUTO)或严格(STRICT)模式。
scss
工具执行级别
├── OFF → 完全绕过守卫(不推荐,仅用于调试)
├── AUTO → 正常检查,自动拒绝高危,自动允许低风险
├── SMART → 类似 AUTO,但自动允许 INFO/LOW 风险的发现(比 AUTO 更聪明地放行)
└── STRICT → 所有工具都需要人工审批(最安全但最慢模式)
(由 agent_config.security.tool_guard.execution_level 控制)
守卫检查流程:
1. 获取 tool_name + tool_input
2. 检查 denied_tools 黑名单 (自动拒绝 ------ 可配置哪些工具永远不准用)
3. 检查是否 guarded 工具 (需额外检查 ------ 只对"有风险"的工具运行守卫)
4. 运行守卫 (guardians):
├── file_guardian → 检查文件路径是否越界 (防止读取 /etc/passwd 等)
├── shell_evasion_guardian → 检查命令注入 (防止 rm -rf / 等)
├── approval_guardian → 是否需要审批 (根据 severity 决定)
└── always_run 守卫 → 对非 guarded 工具也运行 (兜底检查)
5. 根据 GuardResult 的 severity (严重程度) 决定:
├── auto_denied → 自动拒绝 + 返回拒绝消息
├── needs_approval → 发送审批请求 (等待用户确认)
└── auto_allowed → 直接执行
守卫系统的设计借鉴了责任链模式:多个 Guardian 顺序执行,每个 Guardian 独立检查自己的关注点,任何一个 Guardian 返回拒绝,整个调用就被阻断。这种设计让新增安全检查变得非常容易------只要写一个新的 Guardian 类注册进去即可,不需要修改既有代码。
五、提示词系统
提示词系统负责将 Agent 的"人格"和行为准则注入到 System Prompt 中。它的设计亮点是 "模板 + 占位符"模式------AGENTS.md、SOUL.md、PROFILE.md 这些文件是"人的描述文件",由非技术用户编写,而 PromptBuilder 负责在运行时将它们动态拼装成 LLM 可理解的指令。
这种设计带来的灵活性是巨大的:改变 Agent 的行为不再需要改代码,只需要修改 AGENTS.md 中的描述文本。不同的用户可以有各自的 AGENTS.md,同一份代码库可以衍生出不同"性格"的 Agent。
5.1 提示词构成
最终的 sys_prompt 由以下部分拼接而成:
ini
[来自 PromptBuilder]
# AGENTS.md ← Agent 身份描述 (你是谁、你的能力)
(可选包含 heartbeat 心跳节和 memory 记忆节)
# SOUL.md ← Agent 性格/行为准则
# PROFILE.md ← 用户的偏好/背景信息
[来自编码模式]
## Coding Mode ← 编码工作流指南 (仅当 coding_mode 启用)
[来自多模态检测]
<multimodal hint> ← 若模型不支持多模态,提示 Agent 告知用户
[来自环境上下文]
<env_context> ← 当前工作目录、系统信息等
5.2 PromptBuilder 加载逻辑
python
DEFAULT_FILES = ["AGENTS.md", "SOUL.md", "PROFILE.md"]
- 加载顺序固定:AGENTS.md → SOUL.md → PROFILE.md。这个顺序是有意设计的:先定义"你是谁"(AGENTS.md),再定义"你的性格"(SOUL.md),最后告诉 Agent"用户是谁"(PROFILE.md)。这样 Agent 在阅读提示词时,先建立自我认知,再了解行为准则,最后理解服务对象的背景------符合人类的认知逻辑。
- 所有文件可选,不存在则跳过。这使得 Agent 的"人格配置"是渐进增强的:只有一个 AGENTS.md 也能工作,三个文件都配齐则效果最佳。
- 每个文件前自动加
# 文件名作为节标题,在 System Prompt 中形成清晰的文档结构。 - AGENTS.md 支持特殊标记:
<!-- heartbeat:start --> ... <!-- heartbeat:end -->--- 心跳节,可根据开关启用/禁用。心跳节是定期向用户推送状态信息的机制,类似于 IDE 的状态栏通知,但以自然语言呈现。<!-- memory:start --> ... <!-- memory:end -->--- 记忆节,会被移除并替换为 memory_manager 生成的记忆提示。这个占位符机制让 AGENTS.md 的作者不需要关心记忆系统怎么工作------他们只需要声明"这里放记忆",剩下的由代码处理。
5.3 记忆提示注入
PromptBuilder._process_memory_section():
- 删除
AGENTS.md中的<!-- memory:start -->...<!-- memory:end -->区域 - 调用
self.memory_manager.get_memory_prompt(language)生成记忆提示 - 在 AGENTS.md 末尾追加该记忆提示
这个流程的核心意图是分离关注点 :AGENTS.md 的作者只需要在模板中留一个 <!-- memory:start --> 标记,运行时由记忆系统自动填充真实的记忆内容。这意味着记忆系统可以独立演进------即使底层从向量数据库切换到了关系数据库,AGENTS.md 也不需要修改。
六、命令处理器 (CommandHandler)
命令处理器是 Agent 与用户之间的一条"快捷键"通道。它的设计意图是:某些操作(清空对话、压缩记忆、查看历史等)如果也要走 LLM 推理,不仅速度慢,而且浪费 Token------Agent 明明知道 /new 是什么意思,却还要让 LLM 在上下文中"理解"一遍。解决方案是在 reply() 的入口处、ReAct 循环启动之前,拦截所有以 / 开头的输入,由 CommandHandler 直接处理。
这种设计让命令响应做到毫秒级------不经过 LLM 推理,不消耗 Token,不经过工具调用的安全检查链。这也是为什么 /compact 能瞬间压缩数百条对话历史,而如果让 LLM 来做可能需要几秒钟。
用户可以通过 / 开头的命令控制 Agent 行为:
| 命令 | 功能 | 实现 |
|---|---|---|
/compact |
压缩记忆 (自动摘要 + 清理) | 调用 memory_manager.summarize_when_compact() |
/new |
清空当前会话记忆 | 调用 memory.clear() |
/clear |
同上 (别名) | 同上 |
/history |
显示对话历史 | 遍历 memory 输出 |
/compact_str |
显示压缩统计 | 调用 memory_manager.list_summarize_status() |
/summarize_status |
显示摘要任务状态 | 同上 |
/message <id> |
查看某条消息详情 | 按 ID 查找记忆 |
/dump_history |
导出历史为 JSON | 输出到文件 |
/load_history |
从 JSON 加载历史 | 从文件读取 |
/proactive |
触发主动回忆 | 调用 memory_manager 的 proactive 方法 |
/plan |
计划模式状态 (不带参数时) | 查看计划系统状态 |
/plan <desc> |
创建新计划 (带参数) | 传递给 _maybe_inject_skill → 触发计划技能 |
命令在 reply() 入口处拦截,不走 ReAct 循环。
七、钩子系统 (Hooks)
钩子系统是 AgentScope 框架提供的一种事件回调机制,类似于 Web 框架中的中间件。它的设计目的是:在不修改 Agent 核心循环代码的前提下,在特定生命周期点"插入"自定义行为。Agent 的钩子分散在 ReAct 循环的多个位置,每个钩子只负责一件小事------合起来构成了完整的请求处理管线。
Agent 使用 AgentScope 的钩子机制,在 ReAct 循环的关键生命周期点注入行为:
| 钩子类型 | 注册位置 | 作用 |
|---|---|---|
pre_reply |
ContextManager | 回复前准备 (上下文加载、状态检查) |
pre_reasoning |
BootstrapHook | 首次交互引导 |
pre_reasoning |
ContextManager | 推理前上下文截断 |
post_acting |
ContextManager | 工具执行后处理 |
post_reply |
ContextManager | 回复后清理/持久化 |
BootstrapHook
BootstrapHook 是钩子系统中一个具体的实现,它的设计意图是解决 Agent"冷启动"问题------第一次对话时,Agent 对用户一无所知,也没有历史上下文可以参考。BootstrapHook 通过读取 BOOTSTRAP.md 引导文件,为用户的第一条消息补充背景信息,帮助 Agent 快速进入状态。
首次对话时,如果工作目录存在 BOOTSTRAP.md:
- 读取引导内容
- 将其 prepend 到第一条用户消息前
- 写入
.bootstrap_completed标记文件,防止重复触发
BOOTSTRAP.md 的一个典型用法是:在 Agent 启动时告诉它"当前项目是什么、最新进展到哪一步了、用户可能想问什么问题",这样用户的第一个问题就能得到准确的回答,而不是让 Agent 先去猜项目背景。
注意 BootstrapHook 只触发一次(由标记文件控制),之后的对话不再重复注入。这保证了引导信息不会随着对话进行而不断膨胀,浪费 Token。
八、MCP (Model Context Protocol) 集成
MCP 是 Agent 与外部的 AI 工具生态对接的标准协议。可以把它理解为 AI 世界的"USB 接口"------通过标准化的协议,任何实现了 MCP 的服务都可以像即插即用的外设一样接入 Agent,提供图片生成、数据查询、云端 API 调用等能力。
register_mcp_clients(mcp_clients) 方法支持将外部 MCP 工具集成到 Agent 中。这里的关键挑战是连接可靠性:MCP 客户端可能因为网络波动或服务重启而断开连接,因此实现中包含了状态恢复和定时重连机制。
register_mcp_clients(mcp_clients) 方法支持将外部 MCP 工具集成到 Agent 中:
erlang
register_mcp_clients(clients)
│
├── 遍历 MCP 客户端列表
│
├── 对每个客户端:
│ ├── 尝试连接/恢复 (HttpStatefulClient / StdIOStatefulClient)
│ ├── 获取客户端提供的 tools 列表
│ └── 注册到 toolkit (prefix=MCP 工具名 + 客户端名称)
│
└── 如果连接失败:
├── 记录错误
└── 定时重连 (根据错误类型决定重试策略)
MCP 客户端可以在 Agent 运行时热加载/热更新,支持添加、移除、刷新工具列表。
九、多模态处理机制
多模态处理机制解决的核心问题是:不同 LLM 对多模态(图片、视频、音频)的支持能力差异很大。通义千问 VL 系列和 GPT-4V 可以直接理解图片,而一些纯文本模型(如 DeepSeek-V2)碰到图片会报错。如果 Agent 不做处理,用户发了一张图片,纯文本模型直接崩溃------这对用户体验是灾难性的。
这是 _reasoning 重写的核心改进点,体现了两层防御设计:
scss
_reasoning(tool_choice)
│
├── 1. 主动过滤层 (proactive)
│ ├── 检查模型是否支持多模态 (supports_multimodal 缓存)
│ ├── 检查之前是否 reject_media (capability cache)
│ └── 若不支持:
│ ├── 方法 A: request-time 剥离 (通过 formatter 在格式化时剥离)
│ └── 方法 B: memory-time 剥离 (直接修改 memory 中的媒体块)
│
├── 2. 调用 LLM
│ └── super()._reasoning(tool_choice)
│
└── 3. 被动过滤层 (passive fallback)
└── 如果 LLM 抛出 bad_request / media error:
├── 记录到 capability cache (rejects_media=True)
├── 剥离剩余媒体块
└── 重试 _reasoning
为什么要两层?
- 主动层:避免 API 调用失败,节省 Token。如果上一个请求已经发现模型拒绝媒体,就在 capability cache 中标记,后续直接剥离,不会再白花一次 API 调用的钱。
- 被动层:处理主动层没覆盖到的边缘情况(如图片在 tool_result 中)。有些模型的媒体块在用户消息层能被主动过滤,但工具执行返回的图片结果在另一种上下文中,可能仍需被动兜底。
这种两层防御设计的最终效果是:用户无论用什么模型,Agent 都不会因为媒体块而中断工作------最多是告诉用户"我看不到图片,但你可以描述一下"。
十、关键设计模式总结
整个 Agent 系统在多个层面使用了经典的设计模式,每个模式的选择都有其特定的工程考量。下表归纳了各模式的使用位置及设计意图:
| 模式 | 使用位置 | 说明 |
|---|---|---|
| Mixin (多重继承) | QwenPawAgent(CodingModeMixin, ToolGuardMixin, ReActAgent) |
透明拦截 _acting 方法链 |
| 策略模式 | 记忆系统 | BaseMemoryManager 接口,多个后端实现 |
| 工厂方法 | create_model_and_formatter() |
根据配置创建模型和格式化器 |
| 钩子模式 | AgentScope hook 系统 | 生命周期回调 (pre_reply, pre_reasoning, post_acting...) |
| 注册表模式 | 记忆管理器、工具注册 | memory_registry 字典,按名称查找实现类 |
| 建造者模式 | PromptBuilder |
逐步构建系统提示词 |
| 命令模式 | CommandHandler |
系统命令解析和分发 |
| 责任链模式 | ToolGuard guardians | 多个守卫顺序检查,任一可阻断 |
| 代理模式 | ToolGuardMixin._acting |
在真实工具执行前插入安全检查 |
| 模板方法模式 | ReActAgent.reply() → _reasoning() → _acting() → _summarizing() |
固定流程,子类重写特定步骤 |
这里特别值得关注的是 Mixin 模式的运用 :大部分 Python 项目使用 Mixin 只是为了"复用代码",但这里的 ToolGuardMixin 和 CodingModeMixin 复用的是控制流 而非数据或方法。它们通过 super()._acting() 形成调用链,每个 Mixin 在链中扮演一个中间件角色。这种用法在 Python 生态中并不常见,但恰恰是处理"对一个核心方法的横切关注点"最简洁的表达------比装饰器更灵活(装饰器难以形成链式上下文),比组合更透明(组合需要外部包装类显式转发)。
另一个有趣的细节是 注册表模式在记忆系统中的变体 :memory_manager 内部维护了一个 memory_registry 字典,键是记忆类型名称,值是具体的记忆类。当配置指定使用某种记忆时,不是通过 if-else 分支来选择,而是直接从注册表中按名称查找并实例化。这使得新增记忆后端不需要修改任何分支逻辑------只需要在注册表中注册一个新的类即可。
十一、数据流总图 (完整生命周期)
yaml
AgentRunner.run_query()
│
├── 加载会话状态 (SafeJSONSession.load_session_state)
│
├── 创建/复用 QwenPawAgent
│ ├── __init__: 工具注册、技能加载、提示词构建、模型创建、钩子注册
│ │
│ └── 如果复用: 更新 request_context, 重建 system prompt
│
├── agent.reply(msg)
│ │
│ ├── (pre_reply 钩子) → ContextManager: 加载上下文
│ │
│ ├── 检查系统命令 → 若是命令则直接返回
│ │
│ ├── ▸▸▸ ReAct 循环开始 ▸▸▸
│ │ │
│ │ ├── (pre_reasoning 钩子) → Bootstrap + ContextManager
│ │ │
│ │ ├── _reasoning() → 调用 LLM
│ │ │ ├── 多模态过滤 (主动+被动)
│ │ │ └── 返回 assistant_msg (可能含 tool_calls)
│ │ │
│ │ ├── 检查 tool_calls
│ │ │ ├── 无 → break, 返回 assistant_msg
│ │ │ └── 有 → 进入工具执行
│ │ │
│ │ ├── _acting(tool_call) ← 三层拦截
│ │ │ ├── QwenPawAgent: Plan gate + JSON 修复
│ │ │ ├── ToolGuardMixin: 安全检测 + 审批流程
│ │ │ └── ReActAgent: 实际调用工具函数
│ │ │
│ │ ├── (post_acting 钩子) → ContextManager: 处理结果
│ │ │
│ │ └── 回到循环开始 (最多 max_iters 次)
│ │
│ ├── _summarizing() → 若需 compact,自动摘要
│ │
│ └── (post_reply 钩子) → ContextManager: 清理
│
├── 保存会话状态 (SafeJSONSession.save_session_state)
│
└── 返回响应 → Channel → 用户
十二、二次开发最常见的切入点
如果你需要在自己的项目中使用或扩展 Agent,以下是最常用的开发入口和对应的使用场景:
| 需求 | 切入点 | 说明 |
|---|---|---|
| 修改 Agent 行为 | 编辑 AGENTS.md / SOUL.md / PROFILE.md |
无需改代码,只改描述文本 |
| 添加自定义工具 | 编写工具函数 → 在 _create_toolkit() 中注册 |
工具函数需符合 AgentScope 的 tool 装饰器格式 |
| 添加安全检查 | 实现新的 Guardian 类 → 注册到 ToolGuardMixin | 继承 BaseGuardian,实现 check(tool_name, tool_input) 方法 |
| 自定义记忆后端 | 实现 BaseMemoryManager → 注册到 memory_registry |
只需要实现检索、存储、摘要等核心方法 |
| 新增系统命令 | 在 CommandHandler 中添加命令处理函数 |
注册命令名 → 处理函数映射即可 |
| 添加钩子 | 实现 AgentScope 的 Hook 基类 → 在 _register_hooks() 中挂载 |
可在 pre/post 各阶段插入行为 |
| 修改 ReAct 循环 | 重写 QwenPawAgent._reasoning() 或 _acting() |
注意调用链中的 super() 位置 |
| 集成外部服务 | 实现 MCP 客户端 → 通过 register_mcp_clients() 注册 |
标准 MCP 协议,即插即用 |
每个切入点的修改都是局部且有边界的:改 AGENTS.md 不会影响安全系统,加 Guardian 不会影响 ReAct 循环,注册新工具不会影响其他工具。这种低耦合设计是 Agent 架构的核心工程目标------让开发者每次只需要关注一个模块,不必担心改动波及到不相关的部分。