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 的三步):
    1. 新建类继承 HelloAgentsLLM;
    2. 重写 __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 客户端;
    3. 其他所有情况一律 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 根据推断结果完成具体参数配置,两者协同。
  • 四级优先级(从高到低,这是本节必背):
    1. 特定服务商的环境变量:依次检查 MODELSCOPE_API_KEY、OPENAI_API_KEY、ZHIPU_API_KEY 等,一旦发现立刻定下服务商。最直接也最可靠。
    2. 根据 base_url 判断:没有专用密钥但设置了通用的 LLM_BASE_URL 时解析该 URL。域名匹配特征串(api-inference.modelscope.cn、open.bigmodel.cn 等)识别云服务商;看到 localhost 或 127.0.0.1 则看端口——:11434 判为 ollama、:8000 判为 vllm,其他本地端口返回 local。
    3. 分析 API 密钥格式:识别 ms- 这类固定前缀。因为多个服务商的密钥格式可能相似、存在模糊性,所以只作为辅助手段,优先级最低。
    4. 默认返回 "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 → 存历史」;开启工具后,退化成一个极简的文本协议循环。
  • 怎么运作(带工具路径):
    1. _get_enhanced_system_prompt() 把 system prompt 扩成「基础提示词 + 可用工具清单 + 调用格式说明」。工具清单来自 tool_registry.get_tools_description();格式约定为 `[TOOL_CALL:{tool_name}:{parameters}]`,例如 [TOOL_CALL:search:Python编程] 或 [TOOL_CALL:memory:recall=用户信息]。
    2. 主循环(max_tool_iterations 默认 3):调 llm.invoke → 用正则 \[TOOL_CALL:([^:]+):([^\]]+)\] 抽出全部工具调用。
    3. 有调用:逐个执行;把 assistant 回复中的调用标记删掉后作为 assistant 消息入列,再把「工具执行结果: …」以 user 角色插入消息列表——模型下一轮就能「看到」结果,然后继续循环。
    4. 无调用:当前回复即最终答案,跳出。若跑满轮次仍无答案,再调一次 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

  • 转换链路(逐步):
    1. 调 get_parameters() 拿到 ToolParameter 列表;
    2. 逐个参数生成 property:{"type": ..., "description": ...};
    3. 有默认值时,把默认值写进 description 文本(形如 描述 (默认: X))——因为 OpenAI 的 schema 不支持 default 字段;
    4. type == "array" 时补 items 定义(默认 {"type": "string"},即字符串数组);
    5. required=True 的参数名收进 required 数组;
    6. 组装为 {"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. 工程实践要点

  1. 扩展优先用继承、不改库源码;自定义 provider 时务必自己创建 _client,否则父类构造被绕过后会留下一颗定时炸弹。
  2. 「显式传参 > 环境变量 > 默认值」这条顺序在 LLM 层与 Config 层被共同遵守,是框架可预测性的来源。
  3. 提示词的格式约束越「机器友好」,解析越稳:Python 列表、Tool[input]、[TOOL_CALL:...] 是同一个思路的三种形态。
  4. 工具的输出要能安全地进上下文:统一返回字符串、内部捕获异常、给出可读错误提示,而不是向上抛异常;参数默认值要同时写进 description,否则模型看不见。
  5. 多步骤流程用 ToolChain 显式声明数据流,而不是把多步逻辑塞进一个巨型工具里。

7. 高频考点 & 易错点速查

  1. 框架分层:core / agents / tools,原则是「分层解耦、职责单一、接口统一」。
  2. 「万物皆为工具」的含义与价值:除 Agent 类以外,Memory、RAG、RL、MCP 全部抽象为 Tool。
  3. 自动检测的四级优先级及其顺序。高频陷阱:同时存在专用环境变量与本地 base_url 时,专用变量优先,结果为 openai 而非 ollama。
  4. Message 的 role 用 Literal 锁死四个取值;to_dict() 只输出 role + content(对内丰富、对外兼容)。
  5. Agent 基类:ABC + @abstractmethod run 强制统一入口,历史管理下沉基类,get_history() 返回副本。
  6. 正则文本协议 vs 原生 function calling 的鲁棒性差异;FunctionCallAgent 是生产首选,其四个内部方法分工需能复述。
  7. to_openai_schema() 的三个细节:默认值写进 description、array 补 items、required 收集必填参数名。
  8. ToolRegistry 两种注册方式各自的适用场景(复杂工具用对象、现成函数用函数注册)。
  9. ToolChain 靠 output_key + {变量} 模板串联;AsyncToolExecutor 靠线程池 + asyncio.gather 并发,只对 I/O 型任务有效。
  10. 章末习题的考点映射(一句话一条):
    • 四大痛点与「万物皆为工具」的利弊、框架化相对手写代码的具体改进 → 考设计理念与取舍。
    • 新增一个 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 章的智能体协议则需要扩展新的工具类型。因此本章不是终点,而是后续所有高级能力的挂载点。


9. 课后练习

在线试卷:https://md-quiz-online.app.workbuddy.host/

posted @ 2026-09-28 17:07  测试小罡  阅读(10)  评论(0)    收藏  举报