拓冰建站拓冰建站
首页 / 资讯中心 / 正文

手搓大模型智能体脚手架:从零实现Agent工具调用与任务编排

hermes-agent 这个名字听起来像某个神话项目其实是我最近手搓的一个大模型智能体脚手架。它的定位很朴素让大模型不只停留在聊天窗口里而是能自己调用工具、读文件、写纪要、设提醒把一个个零散的API串成一条能自主完成任务的流水线。当时为什么要做这个东西因为团队里大量工作是在几个内部系统之间来回切换客服要查订单、看物流、填工单运营每天要从后台拉数据、生成报表、发通知研发也要反复执行测试、查日志、同步状态。我试过直接写死脚本也试过用纯提示词让模型硬编逻辑最后都撑不住变化。真正能落地的方案是做一个以模型为大脑、以工具为手脚的Agent框架把“用户随口一句话”转成“系统里一串可靠动作”。hermes-agent 就是在这个背景下冒出来的。这篇文章会把整个项目从需求拆解、模块设计到最小实现、排查技巧一次讲清楚。适合正在做AI应用落地、想自己搭Agent框架的开发者也适合被LangChain依赖搞到头大的朋友——你会发现很多核心能力其实几十行代码就能跑起来。1. 项目设计的底层逻辑为什么一定要做Agent1.1 从“模型问答”到“模型干活”只调大模型API的场景本质上是个高级搜索引擎用户问一句模型答一句上下文一关什么都不发生。可一旦想让模型“干活”比如帮用户查完天气再设置一个提醒情况就变了。这里有两个绕不开的问题。第一模型并不知道你的系统里有天气接口、提醒接口、工单接口你需要把这些能力显式暴露给它。第二模型生成的内容不一定是可执行指令它可能只输出一段文字“我已经帮你查好天气了”但实际没有查询动作。这种幻觉是纯文字对话里最坑人的地方。所以我从一开始就决定hermes-agent 不做“对话即答案”而是做成“对话即编排”。用户输入进来之后由模型判断需要调用哪些工具、按照什么顺序调、参数从哪里来然后把调用结果再交给模型组织成自然语言回复。整条链路的核心是循环理解意图 → 生成计划 → 执行工具 → 观察结果 → 继续决策直到任务完成。这个思路其实和人类做事的逻辑很像。你不会一次性把“买菜、做饭、洗碗”规划到每一步而是先出门买菜看到菜市场没有想要的菜再临时调整菜谱。Agent也一样必须边执行边观察边调整。1.2 为什么取名 Hermes以及和成熟框架的取舍Hermes 是希腊神话里的信使神负责传递信息、指引方向。给项目取这个名字就是看中“中间层”这个角色大模型是大脑工具是手脚Hermes 负责在两者之间传话、调度、兜底。它不抢模型的活也不替工具做决定只保证信息准确到达。早期我也纠结过要不要直接用 LangChain 这类成熟框架。后来实际跑了一圈发现重框架最大的问题是“抽象太多黑盒太深”。一个简单的 tool calling 流程框架会在中间包上若干层 callback、memory、chain 对象出了问题根本不知道是模型返回格式错了还是框架内部把参数变了。而且版本升级频繁昨天能跑的代码今天可能就废了。所以在 hermes-agent 里我选择极简自研核心循环只留三个对象——Agent、ToolRegistry、Memory。Agent 负责跑循环ToolRegistry 负责管理工具Memory 负责记录状态。整个核心代码不超过300行任何一层都可以打印日志、单独调试。这不是说成熟框架不能用而是对于需要深度定制、排查问题的内部项目自己掌握核心逻辑往往更踏实。1.3 核心能力拆解四个模块各管一件事模型接入层统一处理不同大模型的API格式让上层代码不关心模型厂商。工具注册层用装饰器暴露函数自动生成模型需要的参数Schema。记忆管理层短期保存对话上下文长期保存任务状态和关键信息。执行循环层负责意图解析、工具调度、结果回填、终止条件判断。这四个模块相互独立又彼此配合。模型接入层只做协议转换工具注册层只做函数描述记忆层只做状态存取最核心的执行循环层把所有东西串起来。后面我会逐个展开讲实现细节重点讲容易踩坑的地方。2. 四大核心模块的细节设计与采坑记录2.1 记忆模块别再傻傻把所有历史都塞给模型记忆模块是我最早动手写的部分也是后来改得最多的部分。最初我天真地把所有对话记录都拼进 Prompt结果上下文一长模型要么开始胡言乱语要么直接报超限。后来才明白记忆要做分层。第一层是短期记忆也就是当前任务轮次的对话记录。这个必须保留但要设置窗口。比如最多保留最近10轮超过的部分可以压缩成摘要再放回去。我用了一个很笨但有效的办法当轮次多到一定程度时调用模型把前面的对话总结成三句话替换掉原始内容。这个“滚动摘要”技巧在大模型应用里非常常见效果也稳定。第二层是任务状态记忆。Agent 执行多步骤任务时往往需要记住中间结果。比如先查了用户所在地天气下一步要根据天气决定是否提醒带伞。这个中间状态如果每次都靠模型从历史记录里重新提取既慢又容易错。我在循环里专门维护了一个state字典工具执行完可以把结果写进去下一步工具能从里面直接读。第三层才是长期记忆。我这里没有上向量数据库而是直接在本地存了 SQLite把每个任务的关键结论、用户偏好、工具执行结果存成结构化记录。等后续需要“还记得我上次让你查的旅行计划吗”这类需求时直接查库不用每次重新理解。实际采坑后发现记忆设计最重要的是“知道哪些不该记住”。工具返回的超长JSON用户随口一句“好的”这些都不该占用上下文。我在工具调用结果回填给模型之前会先做一个裁剪如果返回超过500字就用摘要代替。这一步能让上下文占用减少一半以上。2.2 工具调用封装Function Calling 的正确打开方式模型本身不会调用函数它只会根据你给出的函数定义输出一段结构化 JSON里面包含函数名和参数。这些 JSON 就是“调用意图”。所以工具层的核心工作是两件事把函数转换成模型能理解的 Schema再把模型输出的 JSON 安全地变成真正的函数调用。我在 hermes-agent 里封装了一个tool装饰器。只要在函数上加上装饰器再写好函数名、描述、参数说明注册器就会自动生成模型需要的 JSON Schema。这里有几个参数特别关键函数描述要写清楚“什么时候用这个工具”模型靠这个决定是否调用。参数描述要写清楚每个字段的格式和取值范围模型靠这个生成合法参数。必须声明哪些参数是必填的避免模型漏传关键字段。实际写下来我发现给参数写描述的时间比写函数本身还长。但这一步不能省因为模型对参数的理解完全依赖这段描述。比如一个send_alert(user_id, message)工具如果你不写清user_id的格式模型很可能传成用户的昵称而不是数字ID。另外一个容易忽略的坑是异常处理。模型生成的参数即便格式正确也可能在运行时抛异常。我在调用工具的外面包了一层try / except把异常信息作为“工具执行结果”回传给模型。这样模型就会知道“这个工具调用失败了原因是什么”然后自动换一种方式重试而不是把整个流程中断。这个设计让成功率提升非常明显。2.3 任务规划与循环控制防止 Agent 陷入死循环Agent 循环本身不复杂麻烦的是不可控。模型可能因为某个工具反复返回错误就一直尝试同一个操作也可能在多个工具之间来回跳导致任务永远结束不了。我给循环设了几个硬性约束。第一个是最大轮次限制。每个任务最多允许模型调用 5 次工具达到上限后强制进入总结阶段把已经拿到的结果整理成答案。这个数字可以根据任务复杂程度调整但一定不能省。第二个是重复动作惩罚。我在状态字典里记录每个工具最近一次的调用时间和参数哈希如果模型连续两次请求同一个工具且参数完全相同我会直接截断这次调用并提醒模型“你已经试过这个动作了换个方案”。这个机制一开始没有后来被一次真正死循环逼出来的——当时模型连续五次请求同一个查询接口白白烧掉了大量 token。第三个是工具结果观察。工具执行完以后我会把结果结构化回传而不只是塞一段文本。比如工具返回一个状态码我会解析出success字段让模型先看到“这次调用是否成功”再看到具体内容。这样它就不会对着错误结果一本正经地分析半天。2.4 模型接入层一套代码兼容多家大模型不同厂商的模型API差异很大有 OpenAI 格式的有国内厂商自研格式的还有开源模型本地部署的。如果每个模型都单独写一套 Agent 循环代码会瞬间爆炸。所以我在最底层做了一个 Provider 抽象核心方法只有两个chat()和function_call()。chat()负责纯文本对话function_call()负责把函数定义列表传给模型并解析返回的工具调用。上层 Agent 循环只依赖这两个方法不关心背后是哪个厂商。比如 OpenAI 兼容接口直接用官方SDK国内模型可以通过兼容模式转换本地部署的模型则走 HTTP 接口。这里我的经验是“先兼容 OpenAI 协议再适配其他家”。现在很多开源模型和云厂商都提供 OpenAI 兼容端点连本地跑的 vLLM 也支持。只要把 base_url 和 api_key 配好代码基本不用改。对少数不兼容的接口再用一层 adapter 转换即可。这样做的好处是即使以后要换模型也只是改配置不需要动核心逻辑。3. 从零手写最小可用版 hermes-agent3.1 项目结构和环境准备我建议直接用一个干净的 Python 环境Python 3.10 以上装三个依赖就够了openai兼容SDK、pydantic参数校验、pyyaml配置文件解析。如果不想装 openai SDK直接用requests调 HTTP 接口也行但用 SDK 处理流式输出更方便。mkdir hermes-agent cd hermes-agent python -m venv .venv source .venv/bin/activate pip install openai pydantic pyyaml项目结构我保持精简一共四个文件hermes-agent/ ├── agent.py # 核心执行循环 ├── tools.py # 工具注册与调用 ├── memory.py # 简单记忆管理 └── config.yaml # 模型配置不建 package不做复杂分层先把核心跑通后面再按需拆分。这种“单体起步”的方式对内部项目非常合适因为你根本不知道后面会怎么改过早抽象只会绑住手脚。3.2 核心代码工具注册器工具注册器是所有工具进出的门面。我用一个全局列表保存工具定义用装饰器把函数包装成带schema的对象。关键点是让装饰器既能拿到函数签名又能自动生成参数描述。# tools.py import inspect import json from functools import wraps _TOOL_SCHEMAS [] _TOOL_FUNCS {} def tool(name: str, description: str, parameters: dict): def decorator(func): schema { type: function, function: { name: name, description: description, parameters: { type: object, properties: parameters[properties], required: parameters.get(required, []) } } } _TOOL_SCHEMAS.append(schema) _TOOL_FUNCS[name] func wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper return decorator def get_tool_schemas(): return _TOOL_SCHEMAS def call_tool(name: str, arguments: dict): if name not in _TOOL_FUNCS: raise ValueError(fUnknown tool: {name}) func _TOOL_FUNCS[name] return func(**arguments)这样一个典型的天气查询工具就可以这样写tool( nameget_weather, description查询指定城市的实时天气参数city为城市中文名必须传完整名称, parameters{ properties: { city: {type: string, description: 城市名例如北京} }, required: [city] } ) def get_weather(city: str): # 这里在实际项目中会调用外部天气API # 为了示例直接返回固定结构 return {city: city, weather: 晴, temp: 25}注意我在描述里强调了city必须传完整名称这就是前面说的“给模型写使用说明书”。模型对工具的误用绝大多数情况是参数描述不清晰导致的。与其后面在代码里加各种校验不如一开始把话说透。3.3 核心代码Agent 执行循环Agent 是整个项目的心脏。它做的事情简单说就是把系统提示词、历史对话、工具列表一起发给模型模型返回文本或工具调用请求如果是工具调用执行后把结果回填继续下一轮。# agent.py import json from openai import OpenAI class HermesAgent: def __init__(self, model, base_url, api_key, system_prompt, max_steps5): self.client OpenAI(base_urlbase_url, api_keyapi_key) self.model model self.system_prompt system_prompt self.max_steps max_steps self.messages [{role: system, content: system_prompt}] self.state {} def run(self, user_input: str) - str: self.messages.append({role: user, content: user_input}) step 0 while step self.max_steps: response self.client.chat.completions.create( modelself.model, messagesself.messages, toolsget_tool_schemas(), tool_choiceauto, ) msg response.choices[0].message # 如果模型没有要求调用工具直接返回最终回答 if not msg.tool_calls: self.messages.append({role: assistant, content: msg.content}) return msg.content # 记录模型要调用哪些工具 self.messages.append({ role: assistant, content: msg.content, tool_calls: [ { id: tc.id, type: function, function: { name: tc.function.name, arguments: tc.function.arguments } } for tc in msg.tool_calls ] }) # 依次执行工具并把结果回传给模型 for tc in msg.tool_calls: fn_name tc.function.name fn_args json.loads(tc.function.arguments) print(f[tool] {fn_name}({fn_args})) try: result call_tool(fn_name, fn_args) result_text json.dumps(result, ensure_asciiFalse) except Exception as e: result_text f工具调用失败错误信息{str(e)} self.messages.append({ role: tool, tool_call_id: tc.id, content: result_text }) step 1 # 达到最大步数强制总结 self.messages.append({role: user, content: 所有尝试已完成请根据已有信息回复用户最终结果。}) final self.client.chat.completions.create( modelself.model, messagesself.messages, ) return final.choices[0].message.content这段代码其实已经是一个可用的 Agent 核心了。还有两个细节值得说一下。第一个是tool_calls消息回填时content字段我允许为空。有些模型在返回工具调用时content是None如果不处理后续请求会报格式错误。第二个是工具调用结果统一转成 JSON 字符串方便模型解析结构同时也方便人看日志。3.4 完整示例让 Agent 自己完成“查天气 写纪要”空谈没意思下面给一个实际跑通的示例。假设用户说“帮我看下北京今天适合户外跑步吗如果适合就写一条明天组织跑步活动的群通知。”Agent 的完整执行过程大概是这样的模型生成一个工具调用get_weather(city北京)。Agent 执行工具得到{weather: 晴, temp: 25}。模型拿到结果后判断适合户外跑步于是生成另一个工具调用send_group_notification(content明天下午三点公园北门集合天气晴朗适合跑步)。Agent 执行通知发送返回成功状态。模型汇总整个过程回复用户“北京今天晴25℃适合跑步。已经为你生成明天的群通知。”如果我在群里只给用户看第 5 步用户会觉得顺理成章。但实际上每一步背后都有 Agent 循环在兜底尤其是当工具执行失败时模型会自动调整策略。比如通知接口返回“群名不存在”模型会追问用户是哪几个群而不是傻傻地再调一次。为了更接近线上环境我把配置文件独立出来用 YAML 管理# config.yaml model: gpt-4o-mini base_url: https://api.openai.com/v1 api_key: ${OPENAI_API_KEY} system_prompt: | 你是一个办公助手。需要查询信息时使用工具完成不要凭空编造。 工具调用失败时根据错误信息换一种方式或明确告诉用户无法完成。 所有最终回答使用简洁自然的中文。读取配置的代码也很简单import os import yaml def load_config(pathconfig.yaml): with open(path, r, encodingutf-8) as f: cfg yaml.safe_load(f) # 支持从环境变量读取key if cfg.get(api_key, ).startswith(${) and cfg[api_key].endswith(}): env_name cfg[api_key][2:-1] cfg[api_key] os.environ.get(env_name, ) return cfg这样换模型、换API地址只需要改配置代码不用动。4. 常见问题与排查技巧实录4.1 模型不按 schema 输出怎么办最常见的问题是模型返回的arguments不是合法 JSON或者参数名拼错。遇到这种情况我第一反应不是骂模型而是检查函数定义里的描述是否够清楚。我踩过最典型的一个坑是工具参数明明叫user_id我在描述里只写了“用户ID”没写“数字字符串”。结果模型输出了user: 张三。后来我把描述改成“用户ID10位数字字符串来自用户列表”问题立刻消失。所以排查思路是先确认工具描述是否包含了“参数格式”“取值来源”“边界条件”。如果描述已经很清楚但模型还是偶尔输出坏 JSON就需要在解析层做兜底。我在代码里用json.loads接住异常如果解析失败会尝试用正则从字符串里提取 JSON 片段。再不行就把原始arguments内容作为错误信息回传给模型让它自己修正。这个“让模型自己纠错”的思路在很多场景下非常有用。4.2 上下文越写越长模型开始答非所问跑一段时间后你会发现工具调用结果、中间对话全部堆在messages里Prompt 越来越大模型响应变慢、质量下降。这是所有 Agent 框架都会遇到的问题。我目前的策略是三层清理。第一层每轮工具执行后对结果做摘要截断超过 500 字的部分不进入下一轮上下文。第二层维护一个滚动窗口只保留最近 6 次关键交互更早的内容折叠成摘要。第三层每个任务结束后把“用户目标、关键工具结果、最终结论”存进 SQLite作为长期记忆。下次用户再提起时直接从 SQLite 找回不再从历史里翻。这三层配合下来单次任务的 token 消耗能下降 40% 以上。代价是偶尔模型会丢失一些特别细枝末节的信息但对于办公场景影响不大。如果你的任务对细节要求极高可以把窗口调大但一定要有上限。4.3 工具并发和超时问题早期版本是串行执行工具。如果模型一次请求返回两个工具调用比如查天气和查日历我会一个接一个跑整体耗时特别长。后来改成concurrent.futures并发执行总耗时就降下来了。并发执行需要注意两点。第一工具之间不能有依赖关系否则就要分两步走。第二每个工具都要设超时时间我用的是future.result(timeout5)超过 5 秒就认为调用失败。这样即使某个接口卡住了也不会拖死整个 Agent。还有一点经验不是所有工具都适合并发。像“发送短信通知”这种有副作用的操作我会强制串行避免意外重复发送。并发和可靠性的平衡需要根据工具类型一个个调整。4.4 调试 Agent 的独家技巧调试 Agent 比调试普通程序痛苦得多因为它每一步都是模型在决策结果有随机性。我的习惯是给 Agent 加上详细日志把每次模型返回的tool_calls和工具执行结果都打印到文件里。# 在调用工具前打印 print(fstep{step} action{fn_name} params{fn_args}) # 在调用工具后打印 print(fstep{step} result{result_text[:200]}...)这两行日志看起来不起眼但排查问题时是救命稻草。模型是看到了什么信息才做出这个决策工具到底返回了什么一目了然。有些问题只看最终输出根本找不到原因一看日志就明白了。另一个技巧是给系统提示词留一个“debug模式”。调试时让模型在每个决策节点前面加上一行注释解释自己为什么选这个工具。虽然这会多消耗一点 token但对验证提示词设计是否合理非常有用。等调稳定了再关掉就好。5. 后续扩展空间和我的个人体会hermes-agent 目前已经在我们内部小范围顶用了很长时间最直观的收益是大量重复性操作变成了“一句话的事”。后来我还在继续打磨几个方向比如给工具调用加入权限控制让不同角色只能调用对应工具再比如把每次 Agent 执行的关键节点记录成结构化日志方便复盘和优化。如果你也想在项目里引入 Agent我的建议是从最小闭环开始不要一上来就追求全能。先定一个边界清晰的任务比如“自动查询天气并生成通知”把这个流程跑通再逐步加工具、加记忆、加权限。框架本身真不是重点重点是你对自己场景的理解有多深。最后分享一个让我少走很多弯路的小习惯每次新加工具都要实测三遍——第一遍直接传合法参数第二遍传残缺参数第三遍传非法参数。把模型在异常情况下的表现记录下来再回头打磨工具描述。很多 Agent 项目“看起来能跑”但一上线就翻车就是因为没人测试过模型误用工具的路径。这一步虽然有笨功夫但绝对值得。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门