Agent 速成笔记 · 第 7 章 构建你的 Agent 框架(HelloAgents)
Agent 速成笔记 · 第 7 章 构建你的 Agent 框架(HelloAgents)
源:Datawhale《Hello-Agents》第 7 章 | 定位:工程实现(自研框架)| 一句话:把前六章的散装知识收敛成一套「分层解耦、职责单一、接口统一」的框架,从此 Agent 的能力靠「加工具」来扩展。
0. 一章速览(30 秒)
- 一句话:HelloAgents = 一个 LLM 调用中枢 + 三件套接口(Message / Config / Agent 基类)+ 五种 Agent 范式 + 一套以 ToolRegistry 为中心的工具系统。
- 本章解决什么问题:把第 3~6 章各自为战的示例代码,重构成有分层、有抽象、能按章增量扩展的框架,让后续的记忆、RAG、协议都以「加一个工具」的方式接进来。
- 必须记住的 5 个点:
- 目录三层:
core/(基础设施)、agents/(范式实现)、tools/(能力扩展)。 - 「万物皆为工具」:除核心 Agent 类以外,Memory、RAG、RL、MCP 全部抽象为 Tool。
- LLM 层自动检测优先级:特定服务商环境变量 >
base_url域名/端口 > API Key 格式前缀 > 默认auto。 Message.to_dict()只吐role+content,是「对内丰富、对外兼容」的落点。- 工具调用有两条路:正则文本协议(脆弱)与原生 function calling(鲁棒,生产首选)。
- 目录三层:
1. 为什么自建框架:痛点、理念与分层(7.1)
1.1 主流框架的四个痛点
- 是什么:过度抽象的复杂性、快速迭代的不稳定性、黑盒化的实现逻辑、依赖关系的复杂性。
- 怎么运作 / 为什么:
- 为追求通用性而堆抽象层 → 简单任务也要先理解 Chain、Agent、Tool、Memory、Retriever 等十几个概念,LangChain 的链式调用对初学者学习曲线陡峭。
- 商业化框架为抢市场频繁改 API → 版本升级后老代码跑不动,维护成本高。
- 核心逻辑封装过严 → 只能依赖文档与社区,社区不活跃时一个反馈长期无人推进,拖慢后续开发。
- 依赖包多、体积大 → 与其他项目配合使用时容易依赖冲突。
- 工程要点:四个痛点的本质是「通用性」与「可控性」的冲突。自建框架的取舍很明确——牺牲广度,换透明度与掌控力。
1.2 从使用者到构建者的三点收益
- 深度理解工作原理:亲手实现每个组件,才能真正看清 Agent 的思考过程、工具调用机制,以及不同设计模式的好坏区别。
- 获得完全控制权:每一行代码都可控,可按需精确调优,不受第三方框架设计理念的束缚。
- 培养系统设计能力:框架构建涉及模块化设计、接口抽象、错误处理等软件工程核心技能。
- 现实动因:金融、医疗、教育等垂直领域需要定制提示词模板、特殊工具集成与安全策略;生产环境对响应时间、内存、并发有严格要求,通用框架的「一刀切」方案往往无法满足。
1.3 四大设计理念(重点是「为什么」)
| 理念 | 具体做法 | 解决的问题 |
|---|---|---|
| 轻量级 + 教学友好 | 核心代码按章节区分;除 OpenAI 官方 SDK 与少数基础库外不引入重型依赖 | 出问题能直接定位到框架自身源码,不必在依赖关系里找答案 |
| 基于标准 API | 不自造抽象接口,直接建在 OpenAI 兼容接口之上 | 迁移到其他框架时底层调用逻辑一致,也不必学新概念模型 |
| 渐进式学习路径 | 每章学习代码保存为可 pip 安装的历史版本 | 每一步升级都是自己写过的代码,不产生概念跳跃 |
| 统一的「工具」抽象 | 除核心 Agent 类外一切皆为 Tools,Memory、RAG、RL、MCP 统一为「工具」 | 消除不必要的抽象层,回归「智能体调用工具」这一最直观逻辑 |
- 易混点:把 RAG、MCP 这种体量很大的模块也叫「工具」,乍看像偷懒;实际是刻意为之——把心智模型压缩成一个,学习者只需掌握一种调用范式。
1.4 三层目录结构与职责
hello_agents/
├── core/ agent.py llm.py message.py config.py exceptions.py
├── agents/ simple_agent.py react_agent.py reflection_agent.py plan_solve_agent.py
└── tools/ base.py registry.py chain.py async_executor.py builtin/(calculator, search)
| 层 | 关键文件 | 职责 |
|---|---|---|
| core | llm.py |
HelloAgentsLLM,统一模型调用入口 |
| core | message.py / config.py / exceptions.py |
消息格式、集中配置、异常体系 |
| core | agent.py |
Agent 抽象基类,定义统一接口规范 |
| agents | 四个 *_agent.py |
各 Agent 范式的具体实现 |
| tools | base.py / registry.py |
工具基类与工具注册表 |
| tools | chain.py / async_executor.py |
工具链管理与异步执行 |
| tools | builtin/ |
内置计算工具与搜索工具 |
- 为什么这样分层:遵循「分层解耦、职责单一、接口统一」。上层只依赖下层抽象——新增范式不必改
core/,新增工具不必改agents/,这也是「按章迭代」能成立的结构前提。 - 学习路径结论:先体验(装包跑示例)、后实现(重写核心函数跑测试验证)。重写后对照测试,是判断自己实现是否正确的标准动作。
2. LLM 层:从单一客户端到调用中枢(7.2)
本节在第 4 章 HelloAgentsLLM 基础上升级,三个目标:多提供商支持、本地模型集成、自动检测机制。
2.1 多提供商:用继承扩展,而不是改源码
- 是什么:引入
provider参数(默认"auto"),让HelloAgentsLLM在内部消化各服务商在环境变量命名、默认 API 地址、推荐模型上的差异,对外只给统一调用体验。 - 怎么运作(扩展 ModelScope 的三步):
- 新建类继承
HelloAgentsLLM; - 重写
__init__(model, api_key, base_url, provider="auto", **kwargs):若provider == "modelscope",自己解析凭证(api_key or os.getenv("MODELSCOPE_API_KEY")、base_url默认https://api-inference.modelscope.cn/v1/),校验 key 非空后设置默认模型(model or os.getenv("LLM_MODEL_ID") or "Qwen/Qwen2.5-VL-72B-Instruct")与temperature、max_tokens、timeout,最后构造OpenAI客户端; - 其他所有情况一律
super().__init__(...)交还父类。
- 新建类继承
- 为什么这么设计:这种「命中自定义分支就接管、否则回退父类」的写法,让扩展方不必理解父类全部实现,也不会破坏既有 provider;
think等方法从父类继承,无需重写。 - 工程要点:直接修改已安装库的源码是不被推荐的做法——会让后续升级变得困难,定制功能随升级丢失。继承 + 重写是「扩展而不侵入」的标准姿势。
- 易错点:自定义分支里必须自己创建
self._client,因为父类的构造逻辑被你绕过了,后续所有调用都依赖这个客户端。
2.2 本地模型接入:VLLM 与 Ollama
- 为什么能无缝接:VLLM 通过 PagedAttention 等技术提升吞吐,Ollama 把模型下载、配置、服务启动封装成一条命令,两者都把模型封装为兼容 OpenAI 标准的 API 服务——这正是第 7.1 节「基于标准 API」理念的回报。VLLM 默认在
http://localhost:8000/v1暴露接口,Ollama 默认在http://localhost:11434/v1。 - 怎么接入:把它们当成一个新的
provider传入即可——provider="vllm"配model与base_url,或provider="ollama"同理。本地服务通常不需要真实 API Key,填任意非空字符串即可。 - 关键约束:
model必须与服务端启动时指定的模型一致(VLLM 对应启动命令里的--model,Ollama 对应ollama run后面那个名字)。 - 零代码切换:把
LLM_BASE_URL、LLM_API_KEY、LLM_MODEL_ID写进.env,代码里只写HelloAgentsLLM(),框架会自动检测为 vllm 或 ollama。 - 工程意义:同一份 Agent 核心代码可在云端 API 与本地模型之间自由切换,等于拿到了部署方式、成本控制与数据隐私的三个调节旋钮。
- 定位澄清:本地方案是对第 3 章 Hugging Face Transformers 直接推理的生产级补充——后者适合入门与功能验证,处理高并发请求时性能有限,通常不作为生产首选。
2.3 自动检测机制:两个方法、四级优先级
- 是什么:
_auto_detect_provider负责依据环境信息推断服务商,_resolve_credentials根据推断结果完成具体参数配置,两者协同。 - 四级优先级(从高到低,这是本节必背):
- 特定服务商的环境变量:依次检查
MODELSCOPE_API_KEY、OPENAI_API_KEY、ZHIPU_API_KEY等,一旦发现立刻定下服务商。最直接也最可靠。 - 根据
base_url判断:没有专用密钥但设置了通用的LLM_BASE_URL时解析该 URL。域名匹配特征串(api-inference.modelscope.cn、open.bigmodel.cn等)识别云服务商;看到localhost或127.0.0.1则看端口——:11434判为 ollama、:8000判为 vllm,其他本地端口返回local。 - 分析 API 密钥格式:识别
ms-这类固定前缀。因为多个服务商的密钥格式可能相似、存在模糊性,所以只作为辅助手段,优先级最低。 - 默认返回
"auto":走通用配置。
- 特定服务商的环境变量:依次检查
_resolve_credentials怎么干活:按provider分支解析,返回(api_key, base_url)二元组。以 openai 为例,api_key依次尝试显式传入、OPENAI_API_KEY、LLM_API_KEY;base_url依次尝试显式传入、LLM_BASE_URL、硬编码默认https://api.openai.com/v1;modelscope 同理,默认base_url是https://api-inference.modelscope.cn/v1/。- 为什么这样排序:专用 key 是最强信号(用户主动配置且指向唯一);URL 是半强信号;key 格式是弱信号。这就是「约定优于配置」——凡是能从环境推断出来的,绝不逼用户写代码。
- 易错点(习题里专门设了坑):若同时设置了
OPENAI_API_KEY与LLM_BASE_URL="http://localhost:11434/v1",第一优先级会先命中OPENAI_API_KEY,最终provider是 openai,而不是 ollama。想让本地服务生效,就不要同时留着其他服务商的专用密钥。
3. 框架接口三件套(7.3)
LLM 层解决了「怎么跟模型通信」,还需要配套接口处理数据流、配置与异常。三个文件:message.py、config.py、agent.py。
3.1 Message:统一消息格式
- 是什么:基于 Pydantic
BaseModel的消息类。用MessageRole = Literal["user", "assistant", "system", "tool"]定义角色类型;字段为content、role、timestamp(默认datetime.now())、metadata。 - 怎么运作:构造时自动补全
timestamp与metadata默认值;to_dict()只返回{"role", "content"}两个键;__str__输出[{role}] {content}。 - 为什么这样设计:
- 用
Literal把role锁死为四个取值,直接对应 OpenAI API 规范——类型安全由框架保证,而不是靠人记住有哪些角色。 content+role是「必须给模型看」的最小集;timestamp与metadata是「框架自己要用」的(日志记录、未来功能扩展),所以保留在对象内部但不外发。
- 用
- 工程要点:
to_dict()是「对内丰富,对外兼容」这一设计原则的落点——内部对象可以随意长胖,出口永远只是 OpenAI 认识的那两个字段。 - 衔接:这里只是最小版本,后续上下文工程章节会在此基础上扩展。
3.2 Config:集中配置 + 环境变量注入
- 是什么:同样基于 Pydantic 模型。配置项按逻辑分组,LLM 配置(
default_model="gpt-3.5-turbo"、default_provider="openai"、temperature=0.7、max_tokens=None)与系统配置(debug=False、log_level="INFO"),另有max_history_length=100。 - 怎么运作:
from_env()类方法读取DEBUG、LOG_LEVEL、TEMPERATURE、MAX_TOKENS等环境变量构造实例;to_dict()输出字典。 - 为什么这样设计:每一项都有合理默认值 → 框架在零配置下也能工作;
from_env()让用户用环境变量覆盖默认值而无需修改代码,部署到不同环境时尤其有用;分组是为了让配置结构一目了然。
3.3 Agent 抽象基类:统一执行入口
- 是什么:继承
abc.ABC的顶层抽象,不能直接实例化。构造参数为name、llm、system_prompt、config;内部持有self._history: list[Message];run(self, input_text: **kwargs) -> str用@abstractmethod装饰。 - 怎么运作:基类提供三个通用方法——
add_message追加消息、clear_history清空、get_history返回self._history.copy();__str__输出Agent(name=..., provider=self.llm.provider)。 - 为什么这样设计:
- 用
@abstractmethod强制所有子类实现run,使五种范式共享同一个执行入口——调用方不需要知道背后是 ReAct 还是 Plan-and-Solve。 - 核心依赖(
llm、config)以构造参数注入而非在基类里自己 new,便于替换与复用同一 LLM 实例。 - 历史管理下沉到基类,因为「积累对话」是所有范式的共同需求(哪怕 SimpleAgent 只是把历史拼进 messages)。
- 用
- 易混点:
get_history()返回的是列表副本,不是内部那个列表对象本身——外部拿到后修改不会污染 Agent 状态。 - 设计模式归纳:抽象基类强制统一接口(模板方法思路)+ Pydantic 做数据校验 + 环境变量做配置注入,三件套合起来就是本章的接口风格。
4. 五种 Agent 范式的框架化(7.4)
框架化重构统一解决三个问题:提示词从「特定任务导向」改为「通用化设计」并加强格式约束与角色定义;接口与格式标准化(相同的初始化参数、方法签名、历史管理机制);支持自定义提示词模板与执行策略。
4.1 SimpleAgent:最基础的对话范式
- 是什么:无工具时它就是「拼 messages →
llm.invoke→ 存历史」;开启工具后,退化成一个极简的文本协议循环。 - 怎么运作(带工具路径):
_get_enhanced_system_prompt()把 system prompt 扩成「基础提示词 + 可用工具清单 + 调用格式说明」。工具清单来自tool_registry.get_tools_description();格式约定为`[TOOL_CALL:{tool_name}:{parameters}]`,例如[TOOL_CALL:search:Python编程]或[TOOL_CALL:memory:recall=用户信息]。- 主循环(
max_tool_iterations默认 3):调llm.invoke→ 用正则\[TOOL_CALL:([^:]+):([^\]]+)\]抽出全部工具调用。 - 有调用:逐个执行;把 assistant 回复中的调用标记删掉后作为 assistant 消息入列,再把「工具执行结果: …」以 user 角色插入消息列表——模型下一轮就能「看到」结果,然后继续循环。
- 无调用:当前回复即最终答案,跳出。若跑满轮次仍无答案,再调一次 LLM 兜底。
- 参数解析策略:参数里含
=就按key=value解析(多个用逗号分隔);不含=时按工具名智能推断——search转成{'query': 参数}、memory转成{'action': 'search', 'query': 参数}、其他转成{'input': 参数}。 - 便利能力:
stream_run()用llm.stream_invoke边收边yield,结束后把完整回复存入历史;add_tool()在无注册表时自动新建ToolRegistry()并把enable_tool_calling打开;另有has_tools/remove_tool/list_tools做动态工具管理。 - 为什么这么设计:它同时扮演两个角色——「最小可用 Agent」和「文本协议工具调用的对照组」。用自然语言 + 正则约束模型输出,不需要任何额外 API 能力,代价是对模型是否守格式极度敏感。
- 易错点:工具结果是以 user 角色回灌的(不是
tool角色),这与原生 function calling 的规范不同;正则要求模型输出恰好符合约定格式,多一个空格或换行就可能解析失败。
4.2 ReActAgent:思考-行动循环的框架化
- 是什么:把第 4 章的 ReAct 装进框架,核心变化是提示词模板化 + 复用统一的
ToolRegistry接口。 - 提示词要点:模板占位
{tools}/{question}/{history};强调「每次只能执行一个步骤」;Action 只有两种合法形态——{tool_name}[{tool_input}]调用工具,或Finish[最终答案]收尾;并提醒「工具返回信息不够时,继续用其他工具或同一工具的不同参数」。 - 怎么运作:
run先清空current_history,进入while current_step < max_steps(max_steps默认 5)循环:拼提示词 →llm.invoke(整段提示词作为一条 user 消息发送)→_parse_output拆出 thought 与 action → 若 action 以Finish开头,解析出最终答案、写入历史并返回 → 否则_parse_action拆出工具名与输入,tool_registry.execute_tool(...)执行,把Action: ...与Observation: ...追加进current_history,进入下一步。 - 为什么这样设计:ReAct 的「记忆」不在
Message历史里,而在current_history: List[str]这个字符串列表里——它被格式化进{history}占位符,所以每轮都是一次全量重述,模型不需要自己维护状态。max_steps是唯一的保险丝,到顶就返回「抱歉,我无法在限定步数内完成这个任务。」 - 工程要点:
max_steps与custom_prompt都是可配置的——前者防死循环,后者允许整体替换提示词模板。 - 易错点:「每次只能一个 Action」是刻意约束。并发多个 Action 会让 Observation 与 Action 对不上号,执行轨迹变脏,稳定性骤降。
4.3 ReflectionAgent:生成-反思-改进
- 是什么:核心是三段式提示词字典
DEFAULT_PROMPTS,键为initial、reflect、refine。 - 怎么运作:
initial要求模型给出「完整、准确」的回答;reflect把{task}与{content}交给模型自审,要求指出不足并给出具体改进建议,并约定「如果回答已经很好,请回答『无需改进』」——这是提前终止的哨兵;refine带上{task}、{last_attempt}、{feedback}要求产出改进版,然后回到反思继续迭代。 - 为什么这么设计:框架版把第 4 章面向代码生成的提示词改成通用化设计,使文本生成、分析、创作等场景都能用;再用
custom_prompts参数整体替换三个模板,就能不改代码地切回「代码审查模式」。 - 工程要点:三个模板必须共用完全一致的变量名约定,否则自定义 prompts 会因缺占位符而失败。
4.4 PlanAndSolveAgent:先规划再执行
- 是什么:两个角色分离——Planner 负责拆解任务,Executor 负责逐条执行。
- 怎么运作:Planner 的提示词强制「输出必须是一个 Python 列表,其中每个元素是一个描述子任务的字符串」,并给出
["步骤1", "步骤2", ...]的格式样板,便于稳定解析;Executor 的提示词收到{question}、{plan}、{history}、{current_step},被要求「专注于解决当前步骤,并仅输出该步骤的最终答案,不要输出任何额外的解释或对话」。 - 为什么这么设计:第 4 章用自由文本输出计划,解析不确定性高;框架化后把计划格式锁成 Python 列表,使「步骤数与顺序」成为可编程结构,配合完整异常处理保证后续步骤稳定推进。
- 定制方式:
custom_prompts支持planner与executor两个键分别替换,例如做成「数学专用」——planner 只输出计算步骤与求总和,executor 只输出数值结果。 - 易错点:Executor 越「话多」,步骤越多越容易跑偏;「只输出当前步骤答案」这条约束是输出质量的关键。
4.5 FunctionCallAgent:改用原生函数调用
- 是什么:hello-agents 在 0.2.8 之后引入的范式,直接使用 OpenAI 原生函数调用机制,而不是靠提示词约束 + 正则解析格式。
- 怎么运作(四个内部分工):
_build_tool_schemas通过工具的 description 构建 OpenAI function calling schema;_extract_message_content从响应中提取文本;_parse_function_call_arguments解析模型返回的 JSON 字符串参数;_convert_parameter_types完成参数类型转换。_invoke_with_tools则从self.llm._client取出底层客户端,把temperature、max_tokens补成兜底值后调用client.chat.completions.create(model=..., messages=..., tools=tools, tool_choice=tool_choice, ...)。 - 为什么更鲁棒:工具名与参数不再由模型「自由发挥成文本」再由正则去猜,而是由 API 层用 JSON Schema 约束、以结构化字段返回——直接消掉了「格式解析」这一整类故障源。这就是它相对提示词约束方式具备更强鲁棒性的原因。
- 工程要点:
tool_choice传"auto"时由模型自行决定是否调用工具;若HelloAgentsLLM未正确初始化客户端,_invoke_with_tools会直接抛RuntimeError——所以自定义 LLM 子类里千万不要漏掉_client的创建。 - 选型结论:需要可靠工具调用时优先选它。
4.6 五种范式选型对照
| 范式 | 关键机制 | 适用场景 | 主要风险 |
|---|---|---|---|
| SimpleAgent | 直连对话;可选 [TOOL_CALL:...] 正则协议 |
单轮问答、演示、最小骨架 | 格式敏感、参数靠猜 |
| ReActAgent | Thought/Action 交替,Finish 终止,max_steps=5 |
需要多步查证的工具型任务 | 步数上限、轨迹靠文本累积 |
| ReflectionAgent | initial/reflect/refine 三段迭代 | 写作、生成质量要求高 | 迭代成本、可能空转 |
| PlanAndSolveAgent | Planner 出 Python 列表,Executor 逐步执行 | 步骤清晰的分解型任务 | 计划质量即能力上限 |
| FunctionCallAgent | OpenAI 原生 tools + tool_choice |
生产环境、需要可靠工具调用 | 依赖底层客户端与模型支持 |
5. 工具系统(7.5)
5.1 Tool 基类与 ToolParameter
- Tool 基类(ABC):
__init__(name, description);两个抽象方法——run(parameters: Dict[str, Any]) -> str与get_parameters() -> List[ToolParameter]。 - 为什么这样设计:
- 统一的
run接口意味着所有工具都是「输入字典、输出字符串」,框架任何位置都能用同一种方式调用工具,不必为每个工具写适配层。 get_parameters是自描述 / 内省能力:工具自己声明需要什么参数,框架才可能自动生成文档、做参数校验,并进一步生成 function calling schema。name与description是元数据,决定工具的「可发现性与可理解性」——清单会直接拼进提示词,模型就是靠这两项判断该不该调这个工具。
- 统一的
- ToolParameter(Pydantic BaseModel):字段为
name、type、description、required(默认True)、default(默认None)。有了它,工具的参数需求变成可机读结构,类型检查、默认值、文档生成一体化。
5.2 ToolRegistry:注册、发现、执行
- 是什么:工具系统的管理中枢,内部维护两份字典——
self._tools(Tool 对象)与self._functions(函数式工具)。 - 两种注册方式:
register_tool(tool):注册 Tool 对象,适合复杂工具,保留完整参数定义与验证。register_function(name, description, func):直接注册Callable[[str], str],适合简单工具与快速集成现有函数(参数与返回都是字符串)。- 两者同名时都会打印「已存在,将被覆盖」的警告,而不是静默替换。
- 怎么运作:
get_tools_description()遍历两份字典,生成- 名称: 描述形式的多行字符串;没有任何工具时返回「暂无可用工具」。这段文本直接进 Agent 提示词。to_openai_schema()把工具转成 function calling 标准 schema(见 5.3)。- 另有
get_tool/execute_tool/unregister/list_tools,负责按名取用、执行、移除与列举——Agent 的便利方法正是包了这几个。
- 为什么这么设计:注册表让「能力」与「使用者」彻底解耦——Agent 只认识注册表,不认识任何具体工具。新增能力 = 往注册表里塞一个东西。这就是「万物皆为工具」理念的落地机制。
5.3 to_openai_schema():从 ToolParameter 到 JSON Schema
- 转换链路(逐步):
- 调
get_parameters()拿到ToolParameter列表; - 逐个参数生成 property:
{"type": ..., "description": ...}; - 有默认值时,把默认值写进 description 文本(形如
描述 (默认: X))——因为 OpenAI 的 schema 不支持default字段; type == "array"时补items定义(默认{"type": "string"},即字符串数组);required=True的参数名收进required数组;- 组装为
{"type": "function", "function": {"name", "description", "parameters": {"type": "object", "properties", "required"}}}。
- 调
- 为什么重要:这条链路是「文本协议」与「原生函数调用」之间的桥。同一次工具定义,既可由
get_tools_description()生成给提示词看的中文清单,又可由to_openai_schema()生成给 API 看的 JSON Schema——两种范式共用一套工具定义,无需重复维护。 - 易错点:
type用的是小写字符串(如"string"、"array");默认值不在 schema 字段里,只存在于描述文本中,模型只能靠读描述来推断默认行为。
5.4 自定义工具开发:两个案例
案例一:函数注册式——计算器工具
- 用
ast.parse(expression, mode='eval')把表达式解析成语法树,再以operator.add/sub/mul/truediv映射加减乘除、以math.sqrt/math.pi提供函数与常数,递归求值_eval_node。 - 为什么不用
eval:直接把模型或用户给的字符串丢给eval,等于开了任意代码执行的口子;AST + 白名单只放行显式列出的运算符与函数,是「安全优先」的输入验证思路。 - 返回值约定:表达式为空、解析失败等都返回可读的中文提示字符串而不是抛异常——因为工具的输出要进模型上下文,抛异常会直接打断整个 Agent 循环。
- 注册方式即
register_function(name="my_calculator", description="支持基本运算(+,-,*,/)和 sqrt 函数", func=my_calculate)。
案例二:类注册式——多源搜索工具
- 结构:
SearchTool(Tool),构造参数backend="hybrid"(另有tavily、serpapi两种模式),tavily_key/serpapi_key缺省时从TAVILY_API_KEY/SERPAPI_API_KEY读取;_setup_backends()按「密钥存在 + 依赖可导入」两个条件填充available_backends——列进去的才算真的可用。 - 降级策略(高可用设计的典型):
_search_hybrid优先用 Tavily(AI 优化的搜索),调用参数为search_depth="basic"、include_answer=True、max_results=3;Tavily 抛错则切到 SerpApi 并打印切换日志;若 Tavily 本就不可用则直接用 SerpApi;全部不可用时返回明确提示「没有可用的搜索源,请配置 TAVILY_API_KEY 或 SERPAPI_API_KEY 环境变量」。 - 统一格式化:Tavily 结果先给出直接答案,再列前 3 条结果的标题、内容片段与来源 URL;SerpApi 则取
organic_results的标题与摘要。不同搜索源的异构返回,对上层保持同构。 - 为什么用类而不用函数:这类工具需要维护状态(API 客户端实例、已启用后端列表),函数式注册装不下。
5.5 ToolChain:把工具串成流水线
- 是什么:
add_step(tool_name, input_template, output_key=None)添加步骤;output_key缺省时自动命名为step_{序号}_result。 - 怎么运作:
execute(registry, initial_input, context)先把context["input"] = initial_input,然后按步遍历——用input_template.format(**context)做{变量}替换得到本次工具输入 →registry.execute_tool(...)执行 → 把结果写回context[output_key],供后续步骤引用 → 全部完成后返回最后一步output_key的结果。模板变量缺失会被捕获并返回「工具链执行失败:模板变量未找到」。 - 为什么这么设计:它把「数据在步骤之间怎么流动」显式化为一个 context 字典 + 一组命名输出键,而不是藏在代码里——链路可读、可改、可复用。
ToolChainManager再往上加一层(register_chain/execute_chain/list_chains)负责统一调度。 - 典型例子:
research_and_calculate链——步骤 1 用{input}调search(输出键search_result),步骤 2 用模板「根据以下信息计算相关数值:{search_result}」调my_calculator(输出键calculation_result)。 - 易错点:步骤之间靠
output_key串接,键名写错只会在运行期报「变量未找到」,命名需要当成约定来维护。
5.6 AsyncToolExecutor:并发执行多个工具
- 是什么:基于
concurrent.futures.ThreadPoolExecutor(max_workers=4)的异步执行器。 - 怎么运作:
execute_tool_async用loop.run_in_executor(self.executor, _execute)把同步的registry.execute_tool丢进线程池;execute_tools_parallel为每个{"tool_name", "input_data"}任务创建协程任务,再用asyncio.gather(*async_tasks)一起等待,返回结果列表(顺序与任务顺序一致);__del__里调用executor.shutdown(wait=True)清理资源。 - 为什么这么设计:工具调用绝大多数时间卡在 I/O(HTTP 请求),同步串行是纯浪费。线程池 + asyncio 能在不重写工具实现(工具仍是同步函数)的前提下拿到并发收益——这是它比要求工具自身异步更务实的地方。
- 何时有用:多个互相独立的工具任务,例如同时搜多个关键词、同时算多个表达式。
- 易错点:并发只对 I/O 等待型任务有效;工具内部若有共享可变状态、或对下游服务有速率限制,并行反而会出问题;线程池必须显式释放。
5.7 工具系统开发理念小结
- 设计层面:每个工具遵循单一职责,专注特定功能同时保持接口统一;把完善的异常处理与安全优先的输入验证当作基本要求。
- 性能层面:用异步执行提升并发处理能力,同时合理管理外部连接与系统资源。
6. 工程实践要点
- 扩展优先用继承、不改库源码;自定义 provider 时务必自己创建
_client,否则父类构造被绕过后会留下一颗定时炸弹。 - 「显式传参 > 环境变量 > 默认值」这条顺序在 LLM 层与 Config 层被共同遵守,是框架可预测性的来源。
- 提示词的格式约束越「机器友好」,解析越稳:Python 列表、
Tool[input]、[TOOL_CALL:...]是同一个思路的三种形态。 - 工具的输出要能安全地进上下文:统一返回字符串、内部捕获异常、给出可读错误提示,而不是向上抛异常;参数默认值要同时写进
description,否则模型看不见。 - 多步骤流程用
ToolChain显式声明数据流,而不是把多步逻辑塞进一个巨型工具里。
7. 高频考点 & 易错点速查
- 框架分层:
core/agents/tools,原则是「分层解耦、职责单一、接口统一」。 - 「万物皆为工具」的含义与价值:除 Agent 类以外,Memory、RAG、RL、MCP 全部抽象为 Tool。
- 自动检测的四级优先级及其顺序。高频陷阱:同时存在专用环境变量与本地
base_url时,专用变量优先,结果为 openai 而非 ollama。 Message的role用Literal锁死四个取值;to_dict()只输出role+content(对内丰富、对外兼容)。Agent基类:ABC+@abstractmethod run强制统一入口,历史管理下沉基类,get_history()返回副本。- 正则文本协议 vs 原生 function calling 的鲁棒性差异;
FunctionCallAgent是生产首选,其四个内部方法分工需能复述。 to_openai_schema()的三个细节:默认值写进 description、array 补 items、required 收集必填参数名。ToolRegistry两种注册方式各自的适用场景(复杂工具用对象、现成函数用函数注册)。ToolChain靠output_key+{变量}模板串联;AsyncToolExecutor靠线程池 +asyncio.gather并发,只对 I/O 型任务有效。- 章末习题的考点映射(一句话一条):
- 四大痛点与「万物皆为工具」的利弊、框架化相对手写代码的具体改进 → 考设计理念与取舍。
- 新增一个 provider 的继承式实现、环境变量冲突时最终选谁、VLLM / SGLang / Ollama 多维度对比 → 考 LLM 层扩展。
- Pydantic 校验的实际价值、公开入口与内部实现的分离属于哪种模式、配置集中管理的必要性与不用它会出的问题 → 考三件套接口。
- ReAct 框架化的三个改进点、给反思加质量评分阈值以提前终止、设计一个 Tree-of-Thought 新范式 → 考范式扩展。
- 强制统一工具接口的必要性、工具若需返回多值(标题/摘要/链接)该怎么设计、串联三步以上的工具链、并行执行何时才提速 → 考工具系统。
- 流式输出、多轮对话与分支回溯、第三方插件系统(新增 Agent/工具类型而不改核心)→ 考框架可扩展性。
8. 与前后章节的衔接
本章的输入是前六章的散件:LLM 客户端(第 3、4 章)、ReAct / Plan-and-Solve / Reflection 三种范式(第 4 章)、工具与函数调用(第 4 章),以及工具链所借鉴的「图」的思路(第 6 章)。输出是一套完备的技术底座:统一 LLM 接口、标准消息系统、工具注册机制。
往后看,第 8 章的记忆与 RAG 会以工具形态接入本章框架(Tool 抽象与注册接口已为此预留),第 9 章的上下文工程将直接扩展本章的消息处理机制,第 10 章的智能体协议则需要扩展新的工具类型。因此本章不是终点,而是后续所有高级能力的挂载点。

浙公网安备 33010602011771号