第7章:Agent 层 — Main Crew
本章目标:深入理解 CrewAI 框架下的主 Agent 设计,包括 Agent/Task YAML 配置、Prompt 工程、ReAct 推理循环、step_callback 详细模式,以及工厂模式的应用。本章依赖:第3章(数据模型)、第5章(Runner)、第6章(Session)对应源码:
- [xiaopaw/agents/main_crew.py](file:///d:/ProjectsCodes/企业级智能体实战/xiaopow/xiaopaw/agents/main_crew.py)
- [xiaopaw/agents/models.py](file:///d:/ProjectsCodes/企业级智能体实战/xiaopow/xiaopaw/agents/models.py)
- [xiaopaw/agents/config/agents.yaml](file:///d:/ProjectsCodes/企业级智能体实战/xiaopow/xiaopaw/agents/config/agents.yaml)
- [xiaopaw/agents/config/tasks.yaml](file:///d:/ProjectsCodes/企业级智能体实战/xiaopow/xiaopaw/agents/config/tasks.yaml)
- [xiaopaw/tools/intermediate_tool.py](file:///d:/ProjectsCodes/企业级智能体实战/xiaopow/xiaopaw/tools/intermediate_tool.py)
预计时间:2-3 小时
7.1 什么是 Agent?通俗理解
在聊代码之前,先理解一个概念:Agent(智能体)到底是什么?
想象你有一个非常聪明的助手,你给他一个任务,他:
- 思考:这个任务我应该怎么做?
- 行动:调用工具去完成(查资料、写代码、发消息)
- 观察:看看工具返回了什么结果
- 判断:结果对吗?需要继续吗?还是可以给出最终答案了?
- 回到步骤 1(如果需要的话)
这就是 Agent 的核心工作方式——ReAct(推理-行动)循环。
7.2 CrewAI 框架简介
CrewAI 是一个 Python 框架,帮助我们快速构建 AI Agent。它提供了 Agent、Task、Crew 三个核心概念。
7.2.1 核心概念一览
概念 | 通俗理解 | 在代码中 |
Agent | "一个员工" — 有身份、有目标、有技能 | Agent(role=..., goal=..., tools=...) |
Task | "一个任务" — 具体要做的事情 | Task(description=..., expected_output=...) |
Tool | "一个工具" — Agent 可以调用的能力 | SkillLoaderTool(), IntermediateTool() |
Crew | "一个团队" — 包含 Agent + Task | Crew(agents=[...], tasks=[...]) |
7.2.2 CrewAI 的执行流程
7.3 XiaoPaw 的 Agent 架构
7.3.1 整体设计
7.3.2 设计原则
原则一:极简主 Agent(单工具原则)
主 Agent 只有一个能力扩展工具:
SkillLoaderTool。所有专业能力(PDF 处理、飞书操作、网络搜索等)都通过 Skill 生态接入。这样做的好处:- 主 Agent 的 Prompt 更短(不需要列举所有工具)
- 新增 Skill 不需要修改主 Agent
- 主 Agent 的职责清晰:理解意图 → 编排 Skill
原则二:结构化输出
使用 Pydantic 模型强制 LLM 输出 JSON 格式:
为什么需要结构化输出?
- 程序可以可靠地解析 Agent 的回复(
result.pydantic.reply)
- 可以追踪 Agent 用了哪些 Skill(
result.pydantic.used_skills)
- 避免 LLM 输出格式不一致导致的解析错误
原则三:YAML + Python 分离
配置层 | 位置 | 内容 | 修改频率 |
YAML(人设层) | agents.yaml, tasks.yaml | Agent 的角色、目标、行为规范 | 经常调整(调 Prompt) |
Python(代码层) | main_crew.py | 工具绑定、LLM 绑定、工厂函数 | 较少修改(代码稳定) |
原则四:工厂模式防污染
每次请求创建新的 Crew 实例,防止上一轮的状态污染下一轮。详见 7.7 节。
7.4 Agent 配置详解(agents.yaml)
7.4.1 完整配置
7.4.2 配置字段详解
字段 | 作用 | 对 LLM 的影响 | 示例 |
role | 定义身份/职位 | 影响回复风格和专业程度 | "XiaoPaw 工作助手" |
goal | 定义最终目标 | 指导整个推理方向 | "通过 Skills 完成任务" |
backstory | 背景故事 + 行为规范 | 最重要的 Prompt 工程部分 | 工作流程、边界、策略 |
max_iter | 最大迭代次数 | 安全阀,防止无限循环 | 50 |
7.4.3 backstory 中的 Prompt 工程技巧
技巧一:明确工作流程(Step-by-Step)
为什么 Step-by-Step 有效?因为 LLM 是"逐 token 生成"的,明确的步骤让它更容易产生正确的推理链。
技巧二:明确行为边界(负面约束)
负面约束("不要做什么")和正面约束("要做什么")同样重要。LLM 有时会"过度自信"编造结果,明确禁止可以防止。
技巧三:搜索策略优先级
让 Agent 知道工具之间的优先级关系,避免"大材小用"。
7.5 Task 配置详解(tasks.yaml)
7.5.1 完整配置
7.5.2 Task 的两要素
要素 | 作用 | 示例 |
description | 任务的"输入":告诉 Agent 当前要处理什么 | 历史对话 + 用户消息 |
expected_output | 任务的"输出格式":告诉 Agent 最终要返回什么 | JSON 格式 {reply, used_skills} |
7.5.3 模板变量注入
description 中的 {history} 和 {user_message} 是模板变量,在运行时通过 akickoff(inputs={...}) 注入:注入过程:
7.5.4 结构化输出(output_pydantic)
在 Task 中使用:
使用结果:
7.6 历史格式化(_format_history)
7.6.1 完整实现
7.6.2 输出示例
完整历史只有 4 条时(不截断):
完整历史有 30 条,截断到 20 条时:
7.6.3 设计要点
- 不直接丢弃历史:超出部分不是直接扔掉,而是告知 Agent "存在更早的历史"
- 引导 Agent 使用 history_reader:如果 Agent 需要更早的上下文,会主动调用 history_reader Skill
- 不暴露时间戳:只展示对话内容,不展示原始时间戳(保持简洁)
- 中文角色名:比英文更自然,LLM 处理中文语境时效果更好
7.7 Verbose 模式(step_callback)
7.7.1 什么是 Verbose 模式?
Verbose 模式让 Agent 把"心里想什么"实时告诉用户:
7.7.2 回调构建
7.7.3 ReAct 循环中的回调触发
7.7.4 适用范围
场景 | verbose 推送 | 原因 |
单聊(p2p) | ✅ 推送 | 用户主动开启,希望看到推理过程 |
群聊(group) | ✅ 推送 | 群成员可以看到推理过程 |
话题群(thread) | ❌ 不推送 | 避免话题污染(一个话题里塞太多消息) |
Sub-Crew | ❌ 不注入 | Sub-Crew 的推理过程不需要暴露给用户 |
7.8 工厂模式(build_agent_fn)
7.8.1 为什么需要工厂模式?
先看一个"不用工厂模式"的问题:
CrewAI 的 Agent 在
kickoff() 后会积累内部状态(如执行历史、工具调用记录)。如果复用同一个实例,上一轮的状态会影响下一轮的推理。这就是"状态污染"。7.8.2 工厂模式的实现
7.8.3 闭包原理
通俗理解:闭包就像一个"带记忆的函数"。它记住了创建时的环境(sender、max_history_turns 等),之后每次调用都能使用这些记忆。
7.9 Crew 构建详解(_build_crew)
7.9.1 完整实现
7.9.2 关键参数说明
参数 | 值 | 为什么这样设置 |
temperature | 0.3 | 较低温度让输出更稳定。对于需要精确执行的工作助手,不可预测性是有害的 |
process | Process.sequential | 单 Agent 单 Task,顺序执行即可 |
verbose | True | 在控制台打印 ReAct 过程,方便调试 |
output_pydantic | MainTaskOutput | 强制 JSON 格式输出,程序可解析 |
max_iter | 50 | 足够的迭代次数处理复杂任务,同时防止无限循环 |
7.9.3 IntermediateTool 详解
7.10 完整执行流程
把以上所有部分串联起来,看一次完整的消息处理流程:
7.11 本章小结
本章我们深入学习了 XiaoPaw 的 Agent 层——Main Crew 的完整实现。核心要点回顾:
概念 | 说明 | 位置 |
Agent | 有身份、有目标、有工具的 AI 角色 | agents.yaml + main_crew.py |
Task | 具体的任务描述和期望输出 | tasks.yaml + main_crew.py |
Crew | Agent + Task 的组合体 | main_crew.py 的 _build_crew() |
ReAct 循环 | Thought → Action → Observation 的推理循环 | CrewAI 框架内部 |
step_callback | 每轮 ReAct 后触发的回调,用于 verbose 推送 | _make_step_callback() |
工厂模式 | 每次请求创建新 Crew 实例,防止状态污染 | build_agent_fn() |
关键设计决策:
决策 | 原因 |
单工具原则(SkillLoaderTool) | 主 Agent 极简,能力通过 Skill 扩展 |
YAML + Python 分离 | 人设可调(YAML),代码稳定(Python) |
结构化输出(output_pydantic) | 程序可解析,避免 LLM 输出格式不一致 |
工厂模式(每次新建 Crew) | 防止 CrewAI 内部状态污染 |
历史截断 + history_reader 引导 | 控制上下文长度,不丢失历史 |
低温 LLM(temperature=0.3) | 追求稳定性和可预测性 |
下一步:下一章将深入 Sub-Crew 与 AIO-Sandbox 沙盒的实现,了解 Skill 如何在隔离环境中安全执行。
7.12 练习
- 修改
agents.yaml中的backstory,添加一条新的行为约束(如"回复不能超过 200 字"),观察 Agent 行为变化
- 在
MainTaskOutput中添加一个新字段confidence: float,让 Agent 输出对自己回答的信心程度
- 尝试把
temperature从 0.3 改为 0.8,观察 Agent 的回复风格变化
第8章:Sub-Crew 与 AIO-Sandbox
本章目标:深入理解 Sub-Crew 的创建、MCP 协议、AIO-Sandbox 沙盒通信,以及工具行为约束。对应源码:
8.1 Sub-Crew 设计理念
8.1.1 为什么要 Sub-Crew?
主 Agent 的上下文窗口有限(LLM 处理输入有 token 上限),如果让主 Agent 直接操控所有的文件处理、搜索、飞书工具:
8.1.2 Sub-Crew 的隔离性
维度 | 主 Crew | Sub-Crew |
LLM 上下文 | 完整历史的最近20条 | 完全独立,不知道主对话 |
工具 | SkillLoaderTool + IntermediateTool | Sandbox MCP 工具 |
生命周期 | 每次用户消息新建 | 每个 task 型 Skill 调用新建 |
对话历史 | 不分享 | 不获取 |
文件系统 | 不直接访问 | /workspace/sessions/{sid}/ |
8.2 AIO-Sandbox 沙盒概述
8.2.1 什么是 AIO-Sandbox?
AIO-Sandbox 是一个开源的 Docker 沙盒环境,提供:
- 隔离的代码执行环境(Python、Bash)
- 文件系统操作
- 浏览器自动化
- MCP(Model Context Protocol)接口
8.2.2 MCP 协议
MCP 是 Anthropic 提出的标准化工具调用协议。在 XiaoPaw 中:
8.2.3 Docker 挂载映射
8.3 Sub-Crew 构建(skill_crew.py)
8.3.1 build_skill_crew() 工厂
8.3.2 沙盒行为指令(sandbox_directive)
指令设计要点:
指令 | 目的 |
工作目录/输出目录 | 约束文件路径,防止写入随机位置 |
可用工具列表 | 明确 Sub-Crew 能调用什么 |
规则 1:不编造结果 | 防止 LLM 幻觉 |
规则 3:输出到指定目录 | 统一输出路径,方便主 Agent 定位结果 |
规则 6:--break-system-packages | 解决 Docker 内 pip 安装冲突 |
8.3.3 MCP 工具集
Sub-Crew 连接沙盒时,启用所有 MCP 工具(不使用白名单)。工具约束通过 Agent backstory 行为规则来管理。
为什么不使用
create_static_tool_filter 白名单?web_browseSkill 需要browser_*系列工具,静态白名单会限制这些工具
- 白名单后期维护成本高,新增工具需要修改代码
- Backstory 行为约束更灵活,可以根据 Skill 类型调整指令
8.4 Sub-Crew 调用流程(完整串联)
8.4.1 从 SkillLoaderTool 到 Sub-Crew
8.4.2 时序图
8.5 Sub-Crew 与主 Agent 的 Session
关键点:
- 同一个 session 的两个 Sub-Crew 共享
/workspace/sessions/s-abc/目录
- 第一个 Sub-Crew 的输出文件可以被第二个 Sub-Crew 读取
- 每个 Sub-Crew 是独立实例,不知道前一个 Sub-Crew 做了什么
- 任务上下文(task_context)由主 Agent 负责串联
8.6 Sandbox 凭证注入
8.6.1 write_feishu_credentials()
8.6.2 使用方式
feishu_ops Skill 的 Python 脚本读取凭证:
为什么凭证不经过 LLM?
- LLM 的输入/输出可能被记录到日志和 trace
- 凭证是敏感信息,不应暴露给第三方模型
- 凭证通过文件系统注入沙盒,脚本直接读取
- LLM 不知道凭证的存在,也无法读取
8.7 本章小结
- Sub-Crew:每次 task 型 Skill 调用创建新实例,完全隔离
- AIO-Sandbox:Docker 沙盒,通过 MCP 协议通信
- MCP 工具集:21 个工具全部可用,约束通过 backstory 行为规则
- 凭证安全:飞书/百度凭证写入沙盒文件(chmod 600),不经过 LLM
- 工作目录:Sandbox 路径与主机路径通过 Docker volume 双向映射
下一章将深入 SkillLoaderTool 的实现和 Skills 生态系统。
第9章:Skills 生态系统与 SkillLoaderTool
本章目标:全面理解 XiaoPaw 的 Skills 生态——从定义规范到渐进式披露,从 SkillLoaderTool 的主/子工具切换,到三种特殊处理(reference、task、history_reader)。你将会学到:Skill 是什么、如何定义、如何加载、如何执行,以及整个调用链路是如何衔接的。对应源码:
- xiaopaw/skills/load_skills.yaml — Skill 清单
- xiaopaw/agents/skill_crew.py — Sub-Crew 工厂
9.1 什么是 Skill?—— 用一个比喻理解
在正式进入代码之前,我们先用一个生活中的比喻来理解 "Skill" 这个概念。
9.1.0 比喻:智能手机的应用商店
想象你的手机刚出厂时,只有打电话、发短信等最基本的功能。但当你从应用商店下载了微信、支付宝、地图等 App 后,手机的能力就大大扩展了。
XiaoPaw 的 Skill 生态 就是这个思路:
9.1.1 为什么需要 Skill 生态?
核心问题:如果让主 Agent 直接拥有所有工具(PDF 处理、Word 处理、搜索、飞书操作……),会发生什么?
关键优势:
维度 | 全挂工具 | Skill 生态 |
工具描述 token 消耗 | ~550 tokens(所有工具) | ~150 tokens(只有 Skill 列表) |
选择准确度 | 容易选错 | 更精准(先在菜单中选,再加载详细指令) |
扩展性 | 新增工具需改 Agent 定义 | 只需添加 SKILL.md 文件 |
隔离性 | 一个工具崩溃影响全局 | 每个 Skill 在独立 Sub-Crew 中运行 |
上下文安全 | 工具描述可能泄露敏感信息 | 阶段 0 只暴露名称+简短描述 |
9.2 Skill 定义规范
9.2.1 SKILL.md 格式详解
每个 Skill 目录下必须有一个
SKILL.md 文件。这个文件是 Skill 的"身份证" + "使用说明书",分为两部分:YAML Frontmatter 是 Skill 的"身份证"——描述 Skill 的基本信息,阶段 0 只需解析这部分。
Markdown 正文 是 Skill 的"使用说明书"——详细的执行步骤,阶段 1 按需加载。
实例:pdf Skill 的 SKILL.md
Python Libraries
pypdf - Basic Operations
...(省略更多内容)
不太好的 description 示例:
9.2.3 Skill 的类型:reference vs task
这是 Skill 生态中最核心的概念之一。两种类型的设计目标不同:
维度 | reference(参考型) | task(任务型) |
含义 | 返回操作指南给主 Agent,主 Agent 自己推理 | 启动独立 Sub-Crew,在沙盒中执行 |
执行环境 | 主 Agent 的推理空间 | 独立的 AIO-Sandbox 沙盒 |
上下文 | 共享主 Agent 上下文 | 完全隔离,不感知主对话 |
开销 | 轻量,只是文本返回 | 较重,需要启动 Sub-Crew + MCP 通信 |
适用场景 | 信息型任务、需要主 Agent 综合判断 | 代码执行、文件处理、API 调用 |
示例 | history_reader(读取历史消息) | pdf、docx、feishu_ops、baidu_search |
通俗理解:
reference型 Skill = "给你一本说明书,你自己看着办"
task型 Skill = "派一个专业工人去执行,你等着收结果就行"
9.2.4 load_skills.yaml — Skill 清单
skills/load_skills.yaml 是全局的 Skill 注册表,它告诉 SkillLoaderTool:有哪些 Skill 可用、各自的类型是什么、是否启用。关键设计点:
enabled字段:可以临时关闭某个 Skill,不需要删除文件。比如某个 Skill 的 API Key 还没配置好,设enabled: false即可。
type字段:决定 Skill 的执行方式,SkillLoaderTool 据此分流。
- 注释:每个 Skill 的注释说明了为什么是 task 类型(需要代码执行),帮助维护者理解。
如何新增一个 Skill? 只需三步:
- 创建
skills/新Skill名/SKILL.md文件
- 在
load_skills.yaml中添加条目
- 重启 XiaoPaw,SkillLoaderTool 自动发现新 Skill
9.3 渐进式披露(Progressive Disclosure)
9.3.1 设计原理——为什么分两阶段加载?
这是 SkillLoaderTool 最核心的设计模式。渐进式披露 的意思是:只在需要时才暴露详细信息。
Token 预算分析:
9.3.2 实现细节——_build_description() 方法
这是渐进式披露第一阶段的核心实现。它只读取 SKILL.md 的 YAML frontmatter,构建一个轻量级的 XML 格式工具描述:
为什么用 XML 格式?
XML 格式对 LLM 来说结构清晰,标签明确(
<name>、<type>、<description>),LLM 能准确解析。相比之下,纯文本列表或 Markdown 表格可能被 LLM 误解格式。路径穿越防护:
这段代码的作用是:如果有人恶意配置
name="../../etc",resolve() 会计算出实际路径,然后 startswith 检查确保它仍然在 _SKILLS_DIR 目录下。这是一个重要的安全防护。9.3.3 _extract_frontmatter_description() — 提取 frontmatter 中的 description
通俗解释:
这个方法就像一个"图书管理员"——它只看书的封面(frontmatter),不打开书的内容(Markdown 正文)。这样就节省了时间和资源。
9.4 SkillLoaderTool 实现详解
9.4.1 类结构总览
9.4.2 PrivateAttr —— 为什么有些属性要用 PrivateAttr?
这是 CrewAI
BaseTool 的一个重要机制。让我们用一个表格来对比:属性类型 | 行为 | 用途 |
普通属性 | 被 CrewAI 序列化,传给 LLM | 工具的名称、描述、参数 Schema |
PrivateAttr | 只在 Python 代码中使用,不传递给 LLM | 内部状态、会话 ID、凭据、缓存 |
为什么 session_id 不能暴露给 LLM?
_skill_registry 和 _instruction_cache 为什么也是 PrivateAttr?9.4.3 init 方法
9.4.4 输入输出模型
field_validator 的作用:通俗地说,这个 validator 就像一个"翻译官"。LLM 可能会传
task_context={"page": 1}(一个 dict),但我们的 Schema 定义的是 str 类型。validator 在 Pydantic 校验之前自动把 dict 转成 '{"page": 1}' 字符串,这样就不会报错了。9.4.5 _get_skill_instructions() — 渐进式披露第二阶段
当 LLM 决定调用某个 Skill 时,SkillLoaderTool 需要加载完整的 SKILL.md 内容。这个方法就是渐进式披露第二阶段的核心:
关键设计点总结:
- 缓存机制:
_instruction_cache避免同一 Skill 重复读取文件
- 路径占位符替换:
{skill_base}→ 实际的沙盒路径,让 SKILL.md 可以在不同环境复用
- 花括号转义:
{→{{,防止 CrewAI 模板变量冲突
- sandbox_execution_directive:附加沙盒执行指令,告诉 Sub-Crew Agent 如何使用工具
9.5 三种处理类型详解
当 LLM 调用 SkillLoaderTool 时,
_execute_skill_async() 方法会根据 Skill 的类型进行分流:9.5.1 reference 型 — 返回指令文本
通俗解释:reference 型 Skill 就像"给你一本菜谱"。主 Agent 拿到菜谱后,自己理解步骤,自己决定怎么做。不需要派一个专业厨师(Sub-Crew)去执行。
适用场景:
- 信息型 Skill,如
history_reader
- 需要主 Agent 综合判断的复杂决策流程
- 操作指南长度适中,不会压满上下文窗口
9.5.2 task 型 — 启动 Sub-Crew
通俗解释:task 型 Skill 就像"叫一个专业服务"。比如你要处理 PDF,你自己不会,就叫一个"PDF 处理专家"(Sub-Crew)来帮你做。这个专家有自己的工具(沙盒 MCP 工具),做完后把结果告诉你。
关键点:
- 每次
build_skill_crew()返回新实例,防止状态污染
- Sub-Crew 在独立的沙盒中执行,与主 Agent 完全隔离
- Sub-Crew 的结果通过
akickoff()返回给主 Agent
9.5.3 history_reader — 特殊内联处理
history_reader 是一个特殊的 Skill:它虽然是 reference 型,但不需要从 SKILL.md 读取指令,而是直接在 SkillLoaderTool 内部从内存中读取历史消息。为什么 history_reader 要特殊处理?
原因 | 说明 |
不需要沙盒 | 只是内存中的列表切片操作,不涉及文件系统或代码执行 |
避免网络开销 | 不启动 Sub-Crew,不需要 MCP 协议通信,响应更快 |
安全性 | 历史消息包含用户隐私数据,不应传入沙盒环境 |
可靠性 | 减少中间环节,降低出错概率 |
9.5.4 三种处理类型对比
9.6 SKILL.md 模板变量安全
9.6.1 问题描述
CrewAI 框架使用
{var} 语法进行模板变量替换。当 CrewAI 加载 Agent 的 backstory 或 Task 的 description 时,会自动扫描所有 {变量名} 格式的占位符,然后在 akickoff(inputs={...}) 中查找对应的值。问题来了:SKILL.md 中可能包含代码示例,比如:
如果 CrewAI 发现
{name} 但没有在 inputs 中提供对应的值,就会报错:9.6.2 解决方案
分两步走:
第一步:转义花括号
第二步:注入自映射变量
完整流程图:
通俗解释:
这就像"套娃"——先把
{ 变成 {{(告诉 CrewAI "这是文本,别替换"),然后为了防止 CrewAI 的扫描器仍然报错,主动告诉它"这些变量都存在,值就是它们自己",这样 CrewAI 就不会报错了。9.7 异步双通道 — _arun 与 _run
CrewAI 的
BaseTool 支持两种调用方式:异步 _arun() 和同步 _run()。SkillLoaderTool 同时实现了两者,确保在不同场景下都能正常工作。9.7.1 异步路径 _arun()
9.7.2 同步路径 _run()
为什么需要两个通道?
9.8 完整调用流程 — 端到端走一遍
让我们用一个具体的例子来走一遍完整的调用流程:
场景:用户在飞书中说"帮我分析这个 PDF 文件的内容"
9.9 本章小结
本章我们深入学习了 XiaoPaw 的 Skills 生态系统和 SkillLoaderTool 的实现。核心要点回顾:
知识点 | 核心内容 |
Skill 定义 | SKILL.md = YAML frontmatter(元数据)+ Markdown 正文(操作指南) |
Skill 类型 | reference(返回指令,轻量)vs task(启动 Sub-Crew,重量) |
渐进式披露 | 阶段 0:只加载 frontmatter 的 description(~50 字符/Skill);阶段 1:按需加载完整 SKILL.md |
SkillLoaderTool | 主 Agent 的唯一工具,负责 Skill 发现、加载、分流 |
PrivateAttr | 会话 ID、沙盒 URL 等敏感信息不暴露给 LLM |
三种处理 | reference(返回指令)、task(启动 Sub-Crew)、history_reader(内联分页) |
模板变量安全 | { → {{ 转义 + 自映射注入,防止 CrewAI 报错 |
异步双通道 | _arun()(异步路径,直接 await) + _run()(同步路径,ThreadPoolExecutor) |
路径穿越防护 | resolve() + startswith() 确保 Skill 路径在允许的目录内 |
设计哲学:
- 极简主 Agent:主 Agent 只有一个工具(SkillLoaderTool),所有能力通过 Skill 扩展
- 渐进式披露:按需加载,不浪费 token
- 安全第一:PrivateAttr 保护敏感信息,路径穿越防护,沙盒隔离
- 可扩展性:新增 Skill 只需添加 SKILL.md 文件 + 更新 load_skills.yaml
9.10 练习与思考
- 新增一个 Skill:尝试创建一个名为
hello_world的 task 型 Skill,让它能接收用户的名字并返回问候语。需要: - 创建
skills/hello_world/SKILL.md - 在
load_skills.yaml中注册 - 理解 SkillLoaderTool 如何发现和加载它
- 对比 reference 和 task:
history_reader是 reference 型,但如果把它改成 task 型会有什么不同?分析两种方式的优缺点。
- 模板变量问题:如果 SKILL.md 中有
{user_name}这样的占位符,当前的转义机制会把它变成什么?最终 Sub-Crew 看到的是什么?
- 路径安全:如果
load_skills.yaml中配置了name: "../../etc",SkillLoaderTool 会如何处理?画出代码执行路径。
- 渐进式披露的 token 节省:假设有 10 个 Skill,每个 SKILL.md 平均 3000 字符。计算渐进式披露相比全量加载节省了多少 token(假设 1 token ≈ 4 字符)。
下一章将详细介绍每个 Skill 的具体实现——包括文件处理、飞书操作、搜索、网页浏览等。
第10章:Skill 详解
本章目标:逐一深入分析每个 Skill 的设计目的、操作规范、脚本架构和实现细节。通过本章学习,你将理解每个 Skill 是如何独立工作、如何与沙盒互动、以及如何通过统一的输出规范与主 Agent 通信的。对应目录:xiaopaw/skills/
10.1 分类总览与设计哲学
10.1.1 九大 Skill 一览
Skill | 类型 | 使用沙盒 | 脚本语言 | 核心能力 |
pdf | task | ✅ | Python | PDF 文本提取、合并、拆分、旋转、元数据读取 |
docx | task | ✅ | Python | Word 文档创建、读取、编辑 |
pptx | task | ✅ | Python | PowerPoint 文本提取 |
xlsx | task | ✅ | Python | Excel 数据分析、读写、图表 |
feishu_ops | task | ✅ | Python | 飞书消息发送、文档读取、日历管理、多维表格 |
scheduler_mgr | task | ✅ | Python | 定时任务的创建/查看/更新/删除 |
baidu_search | task | ✅ | Python | 百度千帆网页搜索 |
web_browse | task | ✅ | MCP 工具 | 网页抓取与浏览器自动化 |
history_reader | reference | ❌ | 无(内联) | 对话历史按页读取 |
10.1.2 设计哲学
每个 Skill 都遵循以下设计原则:
10.2 文件处理 Skills
文件处理是 XiaoPaw 最基础的能力之一。当用户在飞书中上传 PDF、Word、Excel 或 PowerPoint 文件时,对应的 Skill 会被触发。
10.2.1 pdf — PDF 文本提取与处理
Skill 定义:
通俗理解:PDF Skill 就像你的"PDF 全能助手"。你给它一个 PDF 文件,它能帮你提取文字、拆分页面、合并多个文件、旋转页面、提取表格等。
SKILL.md 中的核心操作指南:
使用的 Python 库:
库 | 用途 | 为什么选择它 |
pypdf | 基础操作:读取、合并、拆分、旋转、元数据 | 纯 Python 实现,无外部依赖,安装简单 |
pdfplumber | 高级操作:精确文本提取、表格提取 | 比 pypdf 的文本提取更准确,特别是复杂布局 |
为什么需要两个库?
pypdf 适合"编辑"操作(合并、拆分、旋转),pdfplumber 适合"提取"操作(文字、表格)。两者互补,覆盖了常见的 PDF 处理需求。
注意事项:
- 加密 PDF 需要密码才能处理
- 扫描版 PDF(图片)需要 OCR 才能提取文字
- 大文件(>100MB)处理可能较慢
10.2.2 docx — Word 文档处理
Skill 定义:
通俗理解:DOCX Skill 就像你的"Word 助手"。它能读取 Word 文档的内容,也能根据你的需求创建新的 Word 文档。
核心操作:
python-docx 库的核心概念:
概念 | 对应 Word 中的... | 代码操作 |
Document | 整个 Word 文件 | doc = Document(path) |
Paragraph | 一个段落 | doc.add_paragraph("text") |
Run | 段落中的一段文字(相同格式) | run = para.add_run("text") |
Table | 表格 | doc.add_table(rows, cols) |
Heading | 标题(1-9 级) | doc.add_heading("title", level=1) |
10.2.3 pptx — PowerPoint 处理
Skill 定义:
通俗理解:PPTX Skill 能帮你读取 PowerPoint 文件中的文字内容,提取每张幻灯片的标题和正文。
python-pptx 的核心概念:
概念 | 代码 |
整个 PPT 文件 | prs = Presentation(path) |
一张幻灯片 | slide = prs.slides[i] |
幻灯片上的元素 | shape = slide.shapes[j] |
文本框 | shape.text_frame |
段落 | shape.text_frame.paragraphs |
10.2.4 xlsx — Excel 数据处理
Skill 定义:
通俗理解:XLSX Skill 是你的"Excel 数据分析师"。它能读取 Excel 文件中的数据,进行筛选、排序、统计,并生成新的 Excel 文件。
为什么用 pandas?
pandas 是 Python 最强大的数据分析库。相比直接用 openpyxl 操作单元格,pandas 提供的"DataFrame"概念(类似 Excel 的表格视图)让数据分析更加直观和高效。
10.3 feishu_ops — 飞书操作
这是 XiaoPaw 最核心也是功能最丰富的 Skill,它让 Sub-Crew 能够直接与飞书平台交互——发送消息、读取文档、管理日历等。
10.3.1 脚本架构总览
10.3.2 共享认证模块 _feishu_auth.py
这是 feishu_ops 所有脚本的"地基"。每个脚本都通过
import _feishu_auth as auth 引用它,获取飞书 API 的认证 Token 和通用工具函数。为什么需要一个共享模块?
10.3.3 消息发送脚本
send_text.py — 发送纯文字消息
这是最基础的消息发送脚本,也是理解其他脚本的起点:
参数说明:
参数 | 必填 | 说明 | 示例 |
--routing_key | ✅ | 目标路由键,标识消息发给谁 | p2p:ou_abc123(私聊)、group:oc_xyz789(群组) |
--text | ✅ | 消息文本内容 | "你好,这是定时报告" |
send_image.py — 发送图片消息
发送图片比发送文字多一步:先上传图片获取
image_key,再用 image_key 发送图片消息。send_file.py — 发送文件消息
这是处理结果回传的核心场景。用户上传文件 → Skill 处理 → 结果保存到
outputs/ → 调用 send_file.py 将结果发回给用户。10.3.4 文档读取脚本
read_doc.py — 读取飞书文档
read_sheet.py — 读取飞书电子表格
10.3.5 所有脚本的统一设计模式
总结 feishu_ops 所有 16 个脚本的共同模式:
10.4 baidu_search — 百度搜索
10.4.1 Skill 概述
通俗理解:baidu_search Skill 是 XiaoPaw 的"搜索引擎"。当用户问"最近有什么新闻"、"Python 最新版本是什么"这类需要实时信息的问题时,这个 Skill 会去百度搜索最新结果。
10.4.2 凭证注入
搜索 API 需要 API Key。这个 Key 通过环境变量
BAIDU_API_KEY 配置,由 CleanupService 在启动时写入沙盒:10.4.3 搜索脚本详解
参数说明:
参数 | 必填 | 说明 | 示例 |
--query | ✅ | 搜索关键词或自然语言问题 | "Python 异步编程最佳实践" |
--top_k | 否 | 返回结果数,默认 20,最大 50 | --top_k 5(精准搜索)/ --top_k 30(广泛调研) |
--recency | 否 | 时间范围过滤 | week(近7天)/ month(近30天)/ semiyear(近半年)/ year(近一年) |
--sites | 否 | 指定站点(逗号分隔) | "github.com,stackoverflow.com" |
典型使用场景:
10.5 web_browse — 网页浏览与自动化
10.5.1 Skill 概述
通俗理解:web_browse Skill 是 XiaoPaw 的"浏览器"。它能打开网页、提取内容、截图,甚至填写表单、点击按钮——就像真人在操作浏览器一样。
10.5.2 两种工作模式
web_browse 支持两种模式,根据任务需求选择:
模式 | 工具 | 速度 | 适用场景 |
快速提取 | sandbox_convert_to_markdown | 快(~2s) | 提取网页文本内容(新闻文章、文档页面) |
浏览器全功能 | browser_navigate + browser_* 系列 | 慢(~10s) | 截图、表单填写、JavaScript 动态渲染页面 |
为什么需要两种模式?
10.5.3 浏览器自动化工具流程
10.5.4 浏览器工具速查表
工具 | 功能 | 必填参数 | 可选参数 |
browser_navigate | 打开 URL | url | - |
browser_get_markdown | 获取页面 Markdown | - | - |
browser_get_text | 获取页面纯文本 | - | - |
browser_read_links | 获取所有链接 | - | - |
browser_screenshot | 截图 | name | fullPage, selector |
browser_get_clickable_elements | 获取可交互元素 | - | - |
browser_click | 点击元素 | index | - |
browser_form_input_fill | 填写输入框 | index, value | clear |
browser_select | 下拉选择 | index, value | - |
browser_press_key | 按键 | key | - |
browser_scroll | 滚动页面 | amount | - |
browser_evaluate | 执行 JavaScript | script | - |
browser_close | 关闭浏览器 | - | - |
10.6 scheduler_mgr — 定时任务管理
10.6.1 Skill 概述
通俗理解:scheduler_mgr Skill 是 XiaoPaw 的"闹钟管理器"。你可以通过它设置定时任务——比如"每天早上 9 点发送工作摘要"、"每周五下午 5 点生成周报"。
10.6.2 三种触发模式详解
Cron 表达式语法:
常用 Cron 表达式示例:
表达式 | 含义 |
0 9 * * * | 每天 9:00 |
0 9 * * 1-5 | 工作日 9:00 |
0 9 1 * * | 每月 1 号 9:00 |
*/30 * * * * | 每 30 分钟 |
0 9,18 * * * | 每天 9:00 和 18:00 |
10.6.3 任务数据结构
10.6.4 热加载机制
重要提示:scheduler_mgr Skill 只负责管理定时任务配置,不负责执行。触发时的业务处理由主 Agent 配合其他 Skill 完成。
payload.message 必须是自然语言指令,描述"要做什么"。10.7 history_reader — 历史阅读器
10.7.1 Skill 概述
通俗理解:history_reader 是 XiaoPaw 的"记忆回溯器"。当主 Agent 的上下文窗口只能容纳最近 20 条消息时,如果用户问"你之前说的 xxx 是什么意思",history_reader 可以翻出更早的对话记录。
10.7.2 为什么是 reference 型?
history_reader 是唯一一个 reference 型的 Skill,它在 SkillLoaderTool 中内联实现(详见第 9 章),不需要沙盒或 Sub-Crew。
原因:
- 不需要沙盒:只是内存中的列表分页操作,不涉及文件系统或代码执行
- 避免网络开销:不启动 Sub-Crew,不需要 MCP 协议通信
- 安全性:历史消息包含用户隐私数据,不应传入沙盒环境
- 可靠性:减少中间环节,降低出错概率
10.7.3 使用场景
- 用户询问"回顾一下我们之前的对话"
- 历史截断后,Agent 需要查阅更早的消息
- 用户询问特定历史话题("你上次说的那个 Python 库叫什么来着?")
10.8 本章小结
Skill | 核心能力 | 关键技术 | 凭证来源 |
pdf | PDF 文本提取与处理 | pypdf, pdfplumber | 无 |
docx | Word 文档创建与编辑 | python-docx | 无 |
pptx | PowerPoint 文本提取 | python-pptx | 无 |
xlsx | Excel 数据分析 | pandas, openpyxl | 无 |
feishu_ops | 飞书消息/文档/日历 | 飞书 REST API + 16 个独立脚本 | /workspace/.config/feishu.json |
baidu_search | 百度搜索 | 百度千帆 Web Search API | /workspace/.config/baidu.json |
web_browse | 网页浏览与自动化 | sandbox_convert_to_markdown + browser_* 工具 | 无 |
scheduler_mgr | 定时任务管理 | 读写 tasks.json + CronService 热加载 | 无 |
history_reader | 历史分页读取 | 内联实现,内存分页 | 无 |
设计亮点:
- 统一脚本模式:所有 task 型 Skill 脚本遵循相同的参数解析 → 认证 → API 调用 → 响应检查 → 结果输出模式
- 凭证隔离:敏感信息通过文件系统注入沙盒,不经过 LLM
- 独立可测试:每个脚本可以独立运行和测试,不依赖整个系统
- 热加载:scheduler_mgr 的配置变更无需重启
10.9 练习与思考
- 新增一个消息类型:在 feishu_ops 中新增一个
send_markdown.py脚本,支持发送 Markdown 格式的消息。参考 send_text.py 和 send_post.py 的实现。
- 搜索优化:baidu_search 的
_get_api_key()每次都读取文件。如果改成缓存机制,应该如何设计?参考 feishu_ops 的 token 缓存思路。
- 文件处理链:用户上传了一个 Excel 文件,要求"分析数据并生成 PDF 报告"。这需要 chain 哪些 Skill?画出完整的调用链路。
- 错误处理:如果 baidu_search 的 API Key 过期了,脚本会输出什么?Sub-Crew Agent 会如何处理?主 Agent 会给用户什么回复?
- Cron 表达式:用 Cron 表达式表示"每月最后一天 18:00"和"每周一、三、五 9:00"。
下一章将深入 LLM 适配器的实现——如何将阿里云的通义千问模型接入 CrewAI 框架。
第11章:LLM 适配器
本章目标:深入理解 CrewAI 的 LLM 接口机制,掌握 AliyunLLM 适配器的实现细节——重试机制、函数调用(Function Calling)、多模态图片输入、消息格式化、工具结果截断等。
11.1 为什么需要自定义 LLM 适配器?
11.1.1 通俗理解
想象一下,你去国外旅行,发现当地的插座和你的充电器插头不匹配。你需要一个"转换插头"(适配器)才能给你的设备充电。
在 XiaoPaw 中,CrewAI 框架期望一个"标准插座"(OpenAI 格式),而阿里云通义千问(Qwen)提供的是一个"不同形状的插头"(DashScope API)。AliyunLLM 就是这个"转换插头"——它把 CrewAI 的请求翻译成 DashScope 能理解的格式,再把 DashScope 的响应翻译回 CrewAI 能理解的结果。
11.1.2 为什么不能直接用 OpenAI 的适配器?
特性 | OpenAI | DashScope(通义千问) |
API 端点 | api.openai.com | dashscope.aliyuncs.com |
认证方式 | Bearer sk-xxx | Bearer sk-xxx(相同) |
多地域 | 无 | cn(国内)/ intl(国际)/ finance(金融云) |
多模态模型 | gpt-4o | qwen3-vl-plus(需单独指定) |
Tool Calls 格式 | 标准 JSON | 兼容,但部分参数需要规范化 |
上下文窗口 | 基于模型名 | 基于模型名( long→200K, max/plus/turbo→131K) |
11.1.3 核心差异:多模态处理
最大的差异在于多模态图片处理。CrewAI 对工具返回结果的
stringify(字符串化)处理,会导致图片的 base64 编码被当作普通文本,DashScope 无法识别。AliyunLLM 需要反向还原这些字符串为多模态格式。11.2 CrewAI BaseLLM 接口
11.2.1 接口定义
CrewAI 提供了
BaseLLM 抽象类,自定义 LLM 需要继承它并实现以下方法:11.2.2 消息格式
CrewAI 传入
call() 的消息是已转换为 dict 格式的对话历史:11.2.3 两种返回值模式
call() 方法可能返回两种不同的值,取决于 LLM 的决定:当返回
tool_calls 时,CrewAI 会接管执行流程:- CrawlAI 的 executor 接收 tool_calls
- 根据
function.name找到对应的工具函数
- 执行工具函数,获得结果
- 将结果作为
tool消息追加到对话历史
- 再次调用
call(),让 LLM 基于工具结果继续推理
11.3 AliyunLLM 完整实现
11.3.1 类结构总览
11.3.2 初始化详解
为什么重试次数默认是 2?
这是一个经验值。太少(1次)可能不够覆盖偶发性的网络问题;太多(5次以上)会让用户等待过久。2 次重试(共 3 次尝试)在"可靠性"和"响应速度"之间达到了较好的平衡。
11.3.3 多地域支持
11.4 核心流程:call() 方法
11.4.1 调用流程全景图
11.4.2 重试机制详解
重试是 AliyunLLM 最关键的可靠性保障机制。不同于简单的"失败就重试",它根据错误类型采取不同的策略:
重试策略总结:
HTTP 状态码 | 含义 | 是否重试 | 原因 |
200 | 成功 | - | 正常响应 |
4xx | 客户端错误(参数错误等) | ❌ 不重试 | 请求本身有问题,重试不会改变结果 |
429 | 限流(Rate Limit) | ✅ 重试 | 配额可能在下个时间窗口恢复 |
5xx | 服务器错误 | ✅ 重试 | 服务端临时故障,重试大概率成功 |
Timeout | 超时 | ✅ 重试 | 可能是网络抖动 |
11.4.3 空内容重试
LLM 偶尔会返回空内容(
content=""),这通常是因为模型限流或偶发异常。AliyunLLM 对这个情况也做了专门处理:为什么 _retry_on_empty 要设为 False?
这防止了无限递归。如果设为 True,空内容重试内部再次遇到空内容,又会触发重试,形成死循环。设为 False 后,内层重试再遇到空内容就会直接抛出异常,终止循环。
11.5 消息格式化
11.5.1 消息校验
在发送请求前,AliyunLLM 会校验消息格式:
11.5.2 工具结果截断
当工具返回数据过大时(如 Excel 解析结果可能包含大量文本),直接发送会导致 API 返回 500 错误。AliyunLLM 会自动截断过长的工具结果:
11.6 Function Calling 详解
11.6.1 什么是 Function Calling?
Function Calling(函数调用/工具调用)是 LLM 的一项核心能力。它允许 LLM 在推理过程中"调用"外部函数来获取信息或执行操作。
通俗理解:就像你问助手"今天天气怎么样",助手不会凭空编造,而是拿起手机查看天气预报 App,然后把结果告诉你。LLM 的 Function Calling 就是这个过程——它知道自己不能回答某些问题,所以"调用"外部工具来获取信息。
11.6.2 完整调用流程
11.6.3 两种 tool_calls 处理模式
AliyunLLM 的
call() 方法根据 available_functions 参数是否为 None,采取不同的处理方式:为什么有两种模式?
模式 | 谁执行工具 | 适用场景 |
模式 1(递归) | AliyunLLM 自己 | LLM 内部的工具调用循环 |
模式 2(返回) | CrewAI executor | CrewAI Agent 的工具调用流程 |
模式 2 是主要使用的方式。CrewAI 故意传
available_functions=None,让 LLM 只返回原始 tool_calls,由 CrewAI 的 _handle_native_tool_calls 统一执行。这样 CrewAI 可以控制工具的生命周期、记录 trace、管理回调等。11.6.4 MCP 参数规范化
MCP(Model Context Protocol)工具对参数类型有严格要求。LLM 有时会把列表参数传成字符串
"None",导致 schema 校验失败。AliyunLLM 在返回 tool_calls 前做规范化处理:11.6.5 递归 Function Calling
当
available_functions 不为 None 时,AliyunLLM 会递归处理工具调用:11.7 多模态图片处理
11.7.1 问题背景
CrewAI 对工具返回结果做
stringify(字符串化)时,会将图片的 base64 编码当作普通文本处理。但 DashScope 的视觉模型需要图片以特定的多模态格式传入。11.7.2 多模态检测与还原
11.7.3 模型自动切换
当检测到图片时,AliyunLLM 自动将
model 切换为 image_model:为什么需要切换模型?
通义千问的文本模型(如
qwen3.6-max-preview)不支持图片输入。必须使用专门的视觉模型(如 qwen3-vl-plus)才能处理图片。这个切换是自动的,对调用方透明。![多模态模型切换流程]
11.8 上下文窗口大小
什么是 Token?
Token 是 LLM 处理文本的最小单位。粗略地说:
- 1 个中文字 ≈ 1.5-2 个 tokens
- 1 个英文单词 ≈ 1-1.5 个 tokens
- 一段代码 ≈ 更多 tokens(各种符号都是独立的 token)
11.9 异步调用
为什么用
asyncio.to_thread() 而不是 aiohttp?方案 | 优点 | 缺点 |
asyncio.to_thread() | 代码简单,复用同步逻辑 | 占用线程池资源 |
aiohttp | 真正异步,不占用线程 | 需要重写所有 HTTP 逻辑 |
对于 XiaoPaw 的并发量(通常只有几个同时进行的对话),线程池的开销可以忽略不计,而代码简洁性更重要。
11.10 调试模式
当遇到 LLM 调用问题时,可以开启调试模式查看完整的请求内容:
调试模式下,日志会输出完整的请求 payload:
11.11 配置使用
在
_build_crew() 中创建 Agent 时指定 LLM:11.12 本章小结
功能 | 实现方式 | 关键参数 |
多地域支持 | 3 个端点映射 | region: cn / intl / finance |
重试机制 | 按状态码分类重试 | retry_count: 默认 2 |
空内容重试 | 递归调用,最多 2 次 | _retry_on_empty + _empty_retry_count |
Function Calling | 两种模式(递归/返回) | available_functions |
MCP 参数规范化 | 字符串 "None" → [] | _MCP_LIST_PARAMS |
工具结果截断 | 超 12000 字符自动截断 | LLM_TOOL_RESULT_MAX_CHARS |
多模态图片 | 检测 + 还原 + 模型切换 | image_model: qwen3-vl-plus |
消息校验 | 检查 role + tool_call_id | _validate_messages |
上下文窗口 | 基于模型名推断 | get_context_window_size |
调试模式 | 环境变量控制 | QWEN_DEBUG_PAYLOAD=1 |
11.13 练习与思考
- Token 计算:一条 1000 字的中文消息大约消耗多少 tokens?如果上下文窗口是 131,072 tokens,大约能容纳多少条这样的消息?
- 重试策略:为什么 4xx 错误不重试,而 5xx 错误要重试?如果 API 返回 408(Request Timeout),应该怎么处理?
- 多模态处理:如果用户同时上传了 3 张图片,
_normalize_multimodal_tool_result能否正确处理?如果不能,应该如何改进?
- 工具结果截断:如果 LLM 收到截断的工具结果后,仍然尝试基于不完整的信息做结论,会导致什么问题?截断后缀中的提示信息是否足够?
- 模型选择:什么时候应该用
qwen-long(200K 上下文)而不是qwen-max(131K 上下文)?多消耗的 tokens 成本是否值得?
- 自定义适配器:如果要把 XiaoPaw 改为使用 OpenAI 的 GPT-4,需要修改 AliyunLLM 的哪些部分?哪些部分可以复用?
下一章将深入 CronService 定时任务调度系统——如何实现精确的定时触发、任务热加载和调度状态管理。
第12章:CronService 定时任务调度
本章目标:深入理解 CronService 的调度模型、三种调度模式(at / every / cron)、tick 主循环、mtime 热加载机制、任务状态持久化,以及由定时任务触发的消息如何在 Runner 中流转。对应源码:
12.1 调度模型
12.1.1 通俗理解
CronService 就像你的"闹钟管家"。你告诉它"每天早上 9 点提醒我开会",它就会准时在你设定的时间"响铃"——把一条消息注入到 XiaoPaw 的处理管道中,就像有人给你发了消息一样。
12.1.2 数据模型
12.1.3 三种调度模式详解
12.2 CronService 核心实现
12.2.1 类结构
12.2.2 生命周期管理
12.2.3 主循环详解
这是 CronService 最核心的部分。每 0.05 秒执行一次 tick:
12.2.4 触发逻辑
为什么 msg_id 和 sender_id 是伪值?
因为这不是飞书用户的消息,而是定时服务产生的。使用特殊的 msg_id 和 sender_id 可以:
- 日志中容易区分来源
- 避免与真实飞书消息 ID 冲突
- 支持去重(如果同一任务被重复触发)
12.2.5 触发后状态更新
12.2.6 Cron 表达式计算
12.3 热加载(Hot-Reload)
12.3.1 为什么需要热加载?
定时任务不是静态配置——用户可以通过
scheduler_mgr Skill 动态创建、修改、删除任务。这些变更写入 tasks.json 后,CronService 需要立即感知,而不是等下次重启。12.3.2 mtime 检测机制
12.3.3 加载逻辑
12.3.4 原子持久化
12.4 tasks.json 格式
12.5 完整调用链路
12.6 为什么不用 asyncio 精确定时器?
初版 CronService 使用了
asyncio.wait_for(self._sleep.wait(), timeout=...) 的精确等待方式,但后来改为简单的 tick 轮询。原因如下:方案 | 精度 | 代码复杂度 | 可靠性 |
精确定时器 | 微秒级 | 高 | 需要处理 wakeup 和各种边界情况 |
Tick 轮询 | 50ms | 低 | 简单可靠,不易出错 |
对于定时任务来说,50ms 的精度完全够用(用户不会因为 50ms 的延迟而感知到差异),但代码简洁性和可靠性大大提升。
12.7 本章小结
组件 | 功能 | 关键实现 |
CronSchedule | 调度配置 | 三种模式互斥:at / every / cron |
CronPayload | 触发内容 | routing_key + message(自然语言) |
CronState | 运行状态 | next_run / last_run / 状态 / 错误 |
CronService._loop() | 主循环 | 每 50ms tick,检查到期任务 |
CronService._fire() | 触发任务 | 构造伪 InboundMessage → Runner |
CronService._check_mtime() | 热加载检测 | mtime + 文件大小双重比较 |
CronService._save_store() | 原子持久化 | write-then-rename |
_next_cron_ms() | Cron 计算 | croniter 库 + 时区支持 |
设计亮点:
- 统一处理管道:定时消息和用户消息走完全相同的 Runner 链路
- 热加载:mtime 检测 + 立即重载,无需重启进程
- 原子写入:write-then-rename 防止文件损坏
- disabled 保留:禁用的任务不丢失,方便后续重新启用
- cron 重新计算:每次加载都重新计算 cron 的 next_run,确保表达式变更立即生效
12.8 练习与思考
- 精度分析:如果 tick_interval 从 50ms 改为 1000ms(1 秒),对定时任务的精度有什么影响?什么场景下可以接受?
- 原子写入:为什么 _save_store 使用 write-then-rename 而不是直接写入?如果写入过程中进程崩溃,两种方式分别会有什么后果?
- cron 表达式:写出以下场景的 cron 表达式:
- 每月 15 号 10:00
- 每周一、三、五 9:00 和 18:00
- 工作日每小时的第 30 分钟
- 错误处理:如果
_fire()中 dispatch 失败,last_status会被设为 "error"。这个错误信息对用户有什么用?如何改进错误报告?
- 并发安全:如果两个任务同时到期,
_fire()是顺序执行还是并发执行?如果是顺序执行,会不会导致后面的任务延迟触发?
- 时区处理:如果用户在日本(UTC+9)创建了一个 cron 任务
0 9 * * *,但 tasks.json 中 tz 写的是 "Asia/Shanghai"(UTC+8),实际触发时间是什么?如何避免这种问题?
下一章将深入可观测性与日志系统——如何监控 XiaoPaw 的运行状态、记录 LLM 调用轨迹、暴露 Prometheus 指标。
欢迎您在底部评论区留言,一起交流~
Loading...
