前段时间整理7.2版本的项目笔记,把 HelloAgentsLLM 又翻出来过了一遍。这个项目说白了就是一个基于大语言模型的 Agent 示例框架,核心思路是让模型不再只是“聊天”,而是能调用外部工具、读取外部数据、按流程完成任务。7.2 这版我做的主要工作就一个词——扩展。很多人搜“扩展”会搜到浏览器扩展、HEVC 视频扩展、磁盘分区扩展这些完全不相干的东西,但在 LLM 开发这个圈子里,扩展指的是给 Agent 框架新增能力、接入新工具、适配新模型,甚至把单一 Agent 变成多 Agent 协作系统。这个版本我把整个扩展链路理顺了,从工具注册到模型适配,从上下文管理到错误排查,都有一套可复用的操作流程。
这篇文章适合三类人:一类是刚接触 Agent 开发、想知道怎么给现有框架加功能的新手;一类是已经在写 LLM 应用、但每次扩展都靠硬编码、改到怀疑人生的中级开发者;还有一类是想把多个 Agent 串起来做自动化流程的进阶玩家。我会从设计思路、核心扩展点、完整实操、常见问题四个方向展开,把我在 7.2 版本里实际踩过的坑和验证过的方法都写出来,保证每个步骤都能直接复现。
1. 从“能聊天”到“能干活”:HelloAgentsLLM 为什么必须做扩展
1.1 单体 Prompt 的天花板
刚开始接触大语言模型的人都会有个错觉:只要把 Prompt 写得足够长、足够细,模型什么都能干。这句话在简单场景下成立,但一旦涉及真实业务,马上就会露馅。比如你想让 Agent 帮你查数据库里的订单数据、调用内部接口发消息、读取本地文件生成报表,这些操作靠 Prompt 是做不到的——模型本身没有执行能力,它只能“说”,不能“做”。这正是 HelloAgents 这类框架存在的意义:把模型的决策能力和外部工具的执行能力粘合在一起。
而 7.2 版本之前的实现,本质上还是一个单体结构:一个 Agent、一套固定工具、一条 Prompt 模板。想加一个新工具,得改主程序;想换一个模型,得改调用层;想加一段业务逻辑,得改核心代码。改到最后,代码里全是 if else,新的需求一来就牵一发动全身。所以 7.2 版本的第一优先级不是加更多功能,而是先把扩展能力做出来,让这个框架可以“接得住”后续所有的变化。
1.2 扩展的三个维度:能力、模型、流程
我做扩展设计时,把“扩展”这件事拆成了三个独立的维度,每个维度互不干扰,可以单独演进。
第一个是能力扩展。Agent 能干什么,取决于它注册了哪些工具。7.2 里我实现了统一的工具注册机制,任何新增能力都只需要写一个标准的工具函数,然后在配置里声明一下,就能被 Agent 自动发现和调用。这个机制有点类似插头插座的关系,工具是插头,框架是插座,标准统一了,什么电器都能插上去。
第二个是模型扩展。不同任务的性价比差异很大:复杂推理任务用 GPT 级别的模型,简单分类任务用轻量模型,本地私有化部署还需要接开源模型。7.2 做了一个模型适配层,统一了不同厂商 API 的调用格式,切换模型时不需要改业务代码,只需要改一个配置项。
第三个是流程扩展。单 Agent 的能力再强也有边界,多 Agent 协作才是解决复杂问题的正路。7.2 里我加入了简单的 Agent 间消息传递机制,两个 Agent 可以通过共享上下文协作,一个负责拆解任务,一个负责执行任务,后续还可以扩展出更多角色。
这三个维度也是我在前几次版本迭代里反复被需求逼着走之后总结出来的。一开始我图省事,把所有扩展都堆在一个文件里,结果每加一个功能都要重新读一遍全部代码,头大。拆开之后,每个维度的改动都可以独立测试、独立回滚,出问题也容易定位。
1.3 配置驱动还是代码驱动:扩展设计的第一道选择题
做扩展设计时还有一个关键取舍:新增能力的方式是用配置文件声明,还是在代码里硬编码。我见过不少项目选了后者,原因是图方便,但代价是每次加工具都要发版、重启服务,而且新人接手时根本不知道哪个函数被哪里调用了。
7.2 的结论是:工具注册用配置驱动,逻辑复用用代码驱动。也就是说,一个工具函数写好后,它在什么场景下被调用、需要传什么参数、返回什么结果,这些元信息全部放在一个配置表里。Agent 运行时先读配置,再动态加载对应的工具模块。这样加一个新工具,不需要修改框架源码,只需要新增一个工具文件外加一条配置记录,重启之后立即可用。
这个设计在第一版实现时确实要费点功夫,因为动态加载、参数校验、错误处理都要做成通用逻辑。但从项目长远维护的角度看,这步投资非常值得。7.2 版本之后我再加工具,平均耗时不超过十分钟,而且几乎没有因为新工具引入而破坏旧功能的情况。
2. 工具调用扩展:核心机制与实现细节
2.1 Function Calling 的原理:模型如何“知道”有这些工具
工具扩展是 HelloAgentsLLM 最核心的能力,而它底层依赖的是大模型 API 提供的 Function Calling(函数调用)机制。理解了这个机制,你才能把工具扩展做好。
简单说,Function Calling 是这样一个过程:你把工具的描述信息(包括工具名、功能说明、参数结构)以 JSON Schema 的形式传给模型 API,模型根据用户的提问和这些工具描述,决定要不要调用某个工具、参数填什么,然后返回一个结构化的“调用请求”。你的程序收到这个请求后,真正去执行对应的函数,再把执行结果回传给模型,让模型基于结果生成最终的回复。
这个过程本质上就像你把一份“服务菜单”递给了一个超级聪明的店员。店员看了客人的需求,告诉你该上哪道菜、需要什么配料,但真正下厨的还得是你。工具扩展要做的,就是把新菜的菜名、配料、做法写进这份菜单里。
2.2 工具接口规范:参数描述决定调用成功率
在 7.2 里,我定义了一个标准的工具接口,所有工具函数都遵循同一个签名模式。这里直接放出模板代码:
from typing import Any, Dict def my_tool(param1: str, param2: int = 0) -> Dict[str, Any]: """ 工具函数:描述这个工具干什么 Args: param1: 参数1的说明 param2: 参数2的说明 Returns: Dict: 包含执行结果的字典 """ # 在这里写你的业务逻辑 result = {"status": "success", "data": ""} return result这个模板有几个关键点。第一,函数的 docstring 要写清楚用途和参数说明,因为模型会读取这些描述来决定是否调用。第二,尽可能用类型注解,这是给参数校验用的。第三,返回值必须是一个字典,至少包含一个键值对,方便统一处理错误。
接下来是注册信息。在 7.2 中我使用了一个 JSON 文件来管理工具清单,格式如下:
[ { "name": "my_tool", "description": "这个工具用来做什么", "parameters": { "type": "object", "properties": { "param1": { "type": "string", "description": "参数1的说明" }, "param2": { "type": "integer", "description": "参数2的说明" } }, "required": ["param1"] } } ]注意这里的参数描述要尽量详细,尤其是枚举值,一定要写明有哪些可选。否则模型大概率会给你编一个不存在的值出来。我一个血泪教训是:有个工具的参数设计成model_type,取值范围是"fast"和"accurate",但描述里只写了“模型类型”,没写可选值,结果模型返回了个"gpt-4o",接口直接报错。
2.3 动态加载与参数映射
工具函数写好了,配置表也维护好了,接下来就是框架如何把配置变成可调用的函数。7.2 里我用 Python 的importlib做动态导入,实现逻辑不复杂:
import importlib def load_tool(tool_name: str): """根据工具名称加载工具模块,返回可调用对象""" module_path = f"tools.{tool_name}" try: module = importlib.import_module(module_path) func = getattr(module, tool_name) return func except (ImportError, AttributeError) as e: raise RuntimeError(f"工具 {tool_name} 加载失败: {e}")参数映射是另一个容易出问题的点。模型返回的参数可能是 JSON 字符串,需要解析成 Python 字典,然后通过**kwargs传给工具函数。这一层一定要做严格的类型检查,我之前吃过亏:模型返回的数值参数是字符串类型,工具函数里直接做了算术运算,结果炸了。所以 7.2 里我在调用前强制校验参数类型,不匹配就抛异常并反馈给模型重新生成。
2.4 工具执行结果回传与错误处理
工具执行完之后,结果要回传给模型。这一步有个关键细节:回传给模型的结果不一定是给用户看的完整数据。模型上下文窗口有限,尤其是免费或低配模型,上下文太长了不仅费钱,还会影响响应速度。所以我通常在工具函数内部就把结果整理成精简的文本摘要,只保留关键信息。
错误处理也不能马虎。工具调用失败是常态,可能是网络超时、接口限流、参数不合法。7.2 里我统一约定:工具函数执行失败时,必须返回一个包含"error"键的字典,而不是直接抛异常。这样框架层可以捕获这个错误信息,连同提示语一起回传给模型,让模型知道工具没调通,可以尝试换一种方式或向用户说明情况。如果直接抛异常,整个对话流程就断了,用户体验很糟糕。
3. 模型适配层:一套代码接多家模型
3.1 为什么要做模型适配层
HelloAgentsLLM 最早的版本只支持单一厂商的 API,后来实际使用时发现不切实际。企业客户要求私有化部署,要接本地开源模型;个人开发者想把不同任务分流到不同模型上省钱;还有的项目需要在一个流程里“思维链”任务用强模型、简单任务用快模型。如果每次切换模型都去改业务层的代码,那项目基本没法维护。
所以 7.2 里我加入了一个模型适配层,核心是一个统一的调用接口:
class BaseLLMClient: def chat(self, messages: list, tools: list = None, **kwargs) -> dict: """统一聊天接口,tools 为工具定义列表""" raise NotImplementedError def parse_response(self, response: dict) -> dict: """解析模型响应,提取文本或工具调用请求""" raise NotImplementedError所有模型客户端都继承这个基类,实现各自的chat和parse_response方法。业务层只依赖这个抽象接口,不关心底层到底是哪家模型。
3.2 不同模型 API 的差异处理
做这套适配层时,最头疼的是一些细小的差异。比如有的模型把工具调用放在tool_calls字段里,有的放在function_call里;有的模型返回的是归一的 JSON,有的返回的是 Markdown 代码块包的 JSON,有的甚至会在 JSON 之外混入自然语言描述。
我在parse_response里做了兼容处理,核心思路是先把响应文本提取出来,然后做多层解析尝试:
import json import re def extract_json(text: str) -> dict: """从模型返回文本中提取 JSON 对象""" if not text: raise ValueError("空响应") # 移除代码块标记 text = re.sub(r"```(?:json)?", "", text).strip().rstrip("`") try: return json.loads(text) except json.JSONDecodeError: pass # 尝试提取 {} 对 match = re.search(r"\{.*\}", text, re.S) if match: try: return json.loads(match.group()) except json.JSONDecodeError: pass raise ValueError(f"无法从文本中解析 JSON: {text[:200]}")这套解析逻辑实测下来能覆盖绝大多数模型的返回格式。只有极少数模型返回的 JSON 嵌套特别深,正则提取会截断,这种情况我建议直接用该模型官方 SDK 提供的解析能力,不要走通用路径。
3.3 配置驱动的模型切换
模型切换在 7.2 里做成纯配置操作,比如config.yaml里这样配置:
models: default: provider: openai model_name: gpt-4o-mini api_key_env: OPENAI_API_KEY fast: provider: anthropic model_name: claude-3-haiku api_key_env: ANTHROPIC_API_KEY业务代码里使用模型时,通过一个get_model(name)工厂方法获取实例。这样切换模型就是改一行配置的事,不需要碰代码。
要注意的是,不同的模型在 Function Calling 上的能力差异非常大。我实测下来,目前主流大模型对工具调用的支持都已经比较好了,但一些轻量版或开源版本,可能不支持工具调用,或者只能传递少量工具定义。碰到这种情况,适配层要能降级处理:不支持工具调用的模型就退回“把工具说明放进系统提示词,让模型按格式输出指令”的旧方案。这个降级逻辑我在 7.2 里单独封装了一个模块,后续换新模型时可以直接复用。
4. 完整实操:从零扩展一个天气查询工具
4.1 环境准备与项目结构
为了让步骤可复现,我先说一下环境。我的运行环境是 Python 3.10+,安装了openai、anthropic、pyyaml这几个基础依赖。项目结构按功能拆成下面这样:
helloagents/ ├── config.yaml # 全局配置 ├── main.py # 入口 ├── agents/ │ ├── base_agent.py # Agent 基类 │ └── react_agent.py # ReAct 风格 Agent ├── clients/ │ ├── base_client.py # 模型客户端基类 │ ├── openai_client.py │ └── anthropic_client.py ├── tools/ │ └── __init__.py └── tool_registry.json # 工具清单这个结构其实就体现了前面说的扩展思路:新增工具放tools/,注册到tool_registry.json,模型客户端放在clients/,业务逻辑在agents/里编排,互不干扰。
4.2 编写天气工具函数
我们现在来扩展一个天气查询工具。假设我们要对接一个第三方的天气 API,工具函数这样写:
# tools/get_weather.py from typing import Any, Dict import requests def get_weather(city: str, date: str = "") -> Dict[str, Any]: """ 查询指定城市的天气信息 Args: city: 城市名称,例如:北京、上海 date: 日期,格式 YYYY-MM-DD,默认当天 Returns: Dict: 包含天气信息的字典 """ # 实际操作中用 requests 调用天气服务商接口 url = "https://api.example.com/weather" params = {"city": city, "date": date} try: resp = requests.get(url, params=params, timeout=5) resp.raise_for_status() data = resp.json() summary = ( f"{city} {date or '今天'}天气:{data['condition']}," f"温度 {data['low']}~{data['high']}°C," f"湿度 {data['humidity']}%,风力 {data['wind']}" ) return {"status": "success", "summary": summary, "data": data} except Exception as e: return {"error": f"天气查询失败: {str(e)}"}这里有一个实操技巧:返回结果里我专门加了一个summary字段,这是给模型回看用的精简文本,不要把原始 JSON 全量回传,否则上下文会被无关字段大量占用。如果模型需要对天气做进一步分析,可以再让模型在后续对话中追问详情。
4.3 注册工具并验证调用
下一步是在tool_registry.json里登记这个工具:
[ { "name": "get_weather", "description": "查询城市天气,适合回答关于天气、温度、湿度、风力等问题", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如:北京、上海、广州" }, "date": { "type": "string", "description": "日期,格式 YYYY-MM-DD,不填则默认当天" } }, "required": ["city"] } } ]然后重启服务,在测试脚本里发起一个包含工具调用意图的请求:
from agents.react_agent import ReActAgent from clients.openai_client import OpenAIClient client = OpenAIClient.from_config("config.yaml") agent = ReActAgent(client=client, tool_registry="tool_registry.json") response = agent.run("北京今天天气怎么样?需不需要穿外套?") print(response)我实测下来的输出大概是这样的:
北京今天(2025-XX-XX)天气:晴,温度 18~26°C,湿度 40%,风力 3级。 白天体感温度舒适,建议穿薄长袖或短袖加薄外套;早晚略凉,出门可带一件外套。模型能正确触发天气工具、拿到结果后给出建议,说明整条工具调用链路已经通了。从新增代码到实际可用,整个流程不到十分钟。
4.4 扩展到多 Agent 协作场景
单个天气工具只能解决单一问题,但实际业务经常需要多个 Agent 配合。7.2 里我试验了一个场景:一个“行程规划 Agent”负责拆解用户需求,一个“天气 Agent”负责查天气,一个“交通 Agent”负责查交通信息。行程规划 Agent 收到“北京一日游”的需求后,自动把任务拆成“查天气”“查交通”“生成行程”三个子任务,分别调用对应 Agent,汇总结果后输出。
实现方式是给 Agent 增加一个子 Agent 调用工具,本质上和普通工具一样,只是执行体从“函数”变成“另一个 Agent”:
def call_sub_agent(agent_name: str, task: str) -> dict: """调用另一个 Agent 执行任务""" sub_agent = get_agent(agent_name) result = sub_agent.run(task) return {"status": "success", "summary": result}这种设计的好处是,Agent 之间通过消息传递协作,互不感知对方内部实现。新增一个 Agent 角色时,不影响已有 Agent 的稳定运行。缺点也有,就是上下文消耗变高、响应延迟变大,编排复杂任务时需要控制好子任务拆分的粒度,避免任务太小导致反复调用的开销超过收益。
5. 常见问题与排查技巧实录
5.1 工具调用没触发
现象:用户问了“北京天气”,模型直接说“我无法实时查询天气”,没有调用工具。
排查思路:第一,先确认tool_registry.json格式是否正确、工具描述是否准确。模型只有在认为现有工具能解决用户问题时才会触发调用,描述写得不清晰,例如只写了“查天气”没写“可以查询指定城市温度、湿度、风力”,模型可能判断为能力不足。第二,检查模型是否启用了 tools 参数,很多 API 默认不传 tools 就不会启用函数调用。第三,看模型的 max_tokens 是否设置过小,有些模型在工具调用和生成文本之间冲突时,优先截断工具调用。
我遇到的一次典型情况是max_tokens=100,模型刚输出工具调用的开头就被截断了,表现为“模型在说废话,没有真正发起调用”。调大到 512 之后恢复正常。
5.2 模型返回的工具名或参数不存在
现象:模型返回了一个get_weather的调用请求,但参数里有temperature_unit这个字段,工具函数没有这个参数,直接报 TypeError。
这类问题要从两头堵。一头是在工具描述里把参数的枚举值、默认值写清楚,减少模型“自由发挥”的空间。另一头是在框架层做参数过滤:
def safe_call(func, params: dict): import inspect sig = inspect.signature(func) valid_keys = set(sig.parameters.keys()) filtered = {k: v for k, v in params.items() if k in valid_keys} return func(**filtered)如果过滤后必填参数缺失,就不要硬调,把错误信息返回给模型,让它补充参数重新发起调用。
5.3 上下文长度超限
现象:多轮对话后,突然报错,提示上下文超过模型上限。
我在做多 Agent 协作时最先踩到这个坑。每个 Agent 把自己的完整对话历史传给下一个 Agent,几轮下来上下文直接爆掉。解决办法是引入“摘要压缩”:每个 Agent 只对外传递最终结果摘要,不传递内部对话历史。另外,在 Agent 基类里实现一个简单的上下文裁剪逻辑,当对话超过阈值时,把早期的消息压缩成一条系统摘要。
def compress_context(self, messages: list, max_messages: int = 20): if len(messages) <= max_messages: return messages head = messages[:2] # 系统提示词 tail = messages[-max_messages + 2:] summary = self._summarize(messages[2:-max_messages+2]) return head + [{"role": "assistant", "content": f"历史对话摘要:{summary}"}] + tail这个方法不算完美,因为摘要会丢失细节,但在上下文有限的情况下是最实用的兜底手段。如果任务对历史细节敏感,建议优先考虑扩充上下文窗口或换更长的模型,而不是无脑压缩。
5.4 API 限流与并发控制
现象:工具调用频繁或者并发请求一多,API 返回 429 限流错误。
Function Calling 场景下这个问题尤其突出,因为一次用户提问往往要经历“模型返回工具调用请求 → 执行工具 → 结果回传模型 → 模型最终回复”两轮 API 请求,消耗和延迟都是双倍的。7.2 里我做了两个层面的优化:一是给 API 请求加超时和重试机制,指数退避重试三次;二是在工具并发调用时限制并发数,实测并发控制在 5 以内比较稳妥。如果业务要求高吞吐,建议直接购买更高 tier 的 API 配额,而不是在代码层死磕。
下面整理一个速查表,方便排查:
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 模型没有调用工具 | 工具描述不清晰、未传 tools 参数、max_tokens 过小 | 完善工具描述;检查请求参数;调大 max_tokens |
| 工具参数报错 | 模型返回了未定义的参数 | 描述中写清枚举值;框架层做参数过滤 |
| 上下文超限 | 多轮对话累积、多 Agent 互传完整历史 | 实现上下文压缩;只传结果摘要 |
| 限流 429 | API 配额不足、并发过高 | 指数退避重试;控制并发数 |
| JSON 解析失败 | 模型返回了带污染的 JSON | 多层解析策略;优先用官方 SDK |
| Agent 间循环调用 | 子 Agent 任务拆解不合理 | 设置最大递归深度;拆解任务前检查是否原子 |
5.5 一个容易被忽略的小问题:工具结果回传格式
最后说一个小但坑人的细节。工具执行结果回传给模型时,不同模型 API 要求的格式不一样。OpenAI 允许直接传字符串内容,但有些模型要求必须是特定结构体,里面要有content字段。7.2 的适配层里,我在每个模型客户端内部单独处理这个格式转换,避免业务层混乱。这也是为什么我强烈建议一定要在框架层做模型适配,而不是在每个工具函数里直接调 API——否则光是格式适配就能让你改到崩溃。
写在最后:一些实际体会
我做完 7.2 这轮扩展之后,最大的感受是:扩展能力的核心不是“加功能”,而是“定标准”。工具接口统一了,模型适配层统一了,配置格式统一了,后面所有扩展都变成填空题,而不是叙述题。现在新加一个工具,我基本不碰框架代码,把函数写好、配置写好,测试一下就能上线。
如果你正在做一个类似的项目,我的建议是:先做一个最小闭环——让一个工具从注册、调用、回传到最终回复完整跑通,然后再往里面加花样。不要一上来就想把所有模型厂商都适配了,先固定一家把链路打通,再抽象适配层。另外,多读你所用模型官方文档里关于 Function Calling 的详细说明,不同版本之间行为差异很大,文档是最可靠的依据。
后续我打算在这个项目里继续扩展的方向有两个:一是给 Agent 加入短期记忆和长期记忆的分层管理,让多轮对话更自然;二是做一个可视化的 Agent 编排画布,把任务拆解、工具调用、结果汇总这些流程拖拽化,进一步降低使用门槛。如果你也在玩 Agent 扩展,欢迎在评论区交流你的踩坑经验。