尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

AI Agent工程化实战:从零搭建可控的大模型工具调用与任务循环

AI Agent工程化实战:从零搭建可控的大模型工具调用与任务循环 hermes-agent是我最近在持续迭代的一个AI代理工程化项目。简单说它给大模型装了“手”和“脚”以LLM为决策大脑通过Function Calling协议驱动外部工具再配合任务循环、记忆管理和错误恢复去完成那些常规聊天窗口搞不定的自动化任务。很多模型已经具备很强的理解和推理能力但想让它在真实工作流里跑起来——查数据、读写文件、调接口、按步骤完成多环节任务——光靠提示词是不够的。它就是围绕这件事设计的。如果你正在做AI Agent相关开发或者想让大模型承接一些重复性自动化操作这个项目会是一个可参考的样板。它不追求“做一个复杂平台”而是把Agent最核心的骨架做扎实模型接入、工具注册、消息管理、记忆存取。下面我会把设计思路、核心模块、完整搭建过程和踩坑经验全部拆开讲基本可以照着落地。1. 项目整体设计与思路拆解1.1 “Hermes”这个名字代表什么项目定位在哪起名的时候我主要考虑了三点。第一这个代理要干的核心事是“传达指令、协调调度”这让我想起希腊神话里的信使Hermes在诸神之间传递消息、协调事务。第二Agent的本质也是这样把用户意图翻译成工具调用再把工具执行结果传回给模型让模型据此继续决策。第三我希望这个项目足够灵活能接不同的模型、不同的工具而不是绑定在某一个云厂商上。项目定位也由此明确hermes-agent更像一个“代理内核”而不是大而全的业务平台。它不做多租户、不做可视化编排界面专注解决四件事——管理模型会话、声明工具、驱动任务循环、维护记忆。这四件事基本覆盖了个人自动化场景和中小型团队做内部Agent服务的核心需求。说得直白一点它就是一个可以嵌入各种业务系统的Agent运行时上层怎么包装都可以。1.2 核心架构四个模块一个循环很多刚开始接触Agent的朋友会以为做一个Agent就是“给模型塞几个工具定义”但其实工具定义只是第一步。真正跑起来你会发现后面还有模型上下文管理、工具调用的容错、任务状态推进、记忆写入等一堆问题。为了不让逻辑纠缠在一起我把hermes-agent拆成了四个模块各管一摊。Model Layer负责接入LLM统一处理对话补全、工具调用结果返回并屏蔽不同模型厂商的协议差异。Tool Layer负责工具注册、入参校验、执行和结果整理确保模型拿到的工具返回值是可用的结构化数据。Memory Layer负责短期上下文和长期记忆的存取短期是当前任务的完整消息历史长期是有保留价值的背景知识。Orchestrator负责把“模型出决策、执行工具、回填结果、再出决策”这个主循环跑起来并判断什么时候终止。四个模块之间不直接互相调用底层函数而是通过标准化的数据结构通信。比如Orchestrator拿到用户任务后会把当前消息列表和工具Schema一起交给Model Layer模型返回的结果只有两种要么是最终回答要么是工具调用请求如果是后者Orchestrator就交给Tool Layer去执行结果再拼回消息历史然后进入下一轮模型调用。循环一直跑直到模型给出不带工具调用的最终回答或者达到最大轮数上限或者用户主动中断。1.3 为什么自己写而不是直接套现成框架动手前我也认真评估过LangChain、AutoGPT、CrewAI这些方案它们的优势很明显——生态成熟、组件多、社区活跃。但我也发现一个现实问题这些框架为了覆盖太多场景抽象层级往往绕得比较深。你用一个API很顺手一旦想改某个环节的内部逻辑就得先理解好几层封装改起来并不轻松。更关键的是很多框架的“Demo效果”很好但工程细节比较粗糙。日志是否清晰、超时和重试有没有做、上下文爆了怎么裁剪、工具执行失败怎么反馈给模型这些问题在框架里往往要自己补齐。自己写hermes-agent最大的收益就是核心逻辑完全可控。加一个工具、换一个模型、调整记忆策略只需要动一两处代码不需要绕概念。底层的Function Calling协议又是各大家模型共同支持的兼容接口找好切换成本非常低。所以这个项目本质上是“轻框架、重工程”用最少的抽象换取最大的确定性。2. 核心模块解析原理与关键实现细节2.1 Model Layer消息历史和工具Schema是怎么传给模型的模型调用层有一个关键认知工具调用不是把一段函数说明写进Prompt就完事而是要严格遵循模型厂商规定的Tool/Function Calling协议。以大家最常用的兼容接口为例请求消息列表里需要带一个tools参数每个元素包含type: function、function.name、function.description、function.parameters。模型在推理时会看到这些工具如果认为有必要就会在返回内容里带出tool_calls字段里面包含工具名和参数。我在Model Layer里做了一个很薄的封装统一入口就是chat(messages, tools)这个方法。内部根据配置选择走哪个模型服务外部调用方完全不用关心底层厂商是谁。返回值统一整理成两个结构体TextResult表示模型给出了最终作答ToolCallResult表示模型想调用工具。这个抽象不复杂但非常实用因为上层Orchestrator只认这两个结果类型后续就算底层模型换一遍循环逻辑一行都不用改。这里必须强调一个实操经验工具描述一定要写清楚包括“这个工具解决什么问题、每个参数的格式是什么”。模型对模糊描述的误判率会明显上升。比如一个查询天气的工具如果描述只写“查询天气”模型很可能在用户问“北京今天冷吗”时束手无策但如果写清楚“查询指定城市的实时温度、天气状况和风力城市参数用中文城市名”模型基本不会用错。多工具场景下描述写得好不好直接决定任务成功率。2.2 Tool Layer注册、校验、执行、回填一条流水线Tool Layer是hermes-agent比较顺手的部分。我采用“注册表 装饰器”的方式管理工具新增一个工具只需要按约定写一个函数、给函数挂上注册装饰器Orchestrator和Tool Layer这边完全不用改。每个工具必须定义三要素名字、描述、参数Schema。运行时Orchestrator收到tool_calls后先按名字从注册表里找到对应函数再用参数Schema校验模型传进来的参数校验通过才执行。这里有一个特别值得展开的坑工具返回值不要只返回“成功”或“失败”一定要返回模型可以直接拿来推理的结构化内容。举个例子查询天气如果只返回“查询成功”模型根本不知道温度是多少也无法继续回答用户的问题。我习惯让工具直接返回类似{city: 北京, temperature: 25.6, unit: celsius, condition: 晴}这样的JSON模型拿到就是现成的。工具把人能看的原始数据“加工到位”模型才不需要再多问一轮既省token又减少延迟。参数校验也不能省。模型生成的参数偶尔会出现类型错误比如某个字段要求integer模型传了个字符串。如果我们不校验就丢给函数轻则函数抛异常重则出现脏数据污染后续流程。我在注册表里内置了一个轻量校验函数使用JSON Schema的properties和required字段做检查不通过就返回一条明确的错误消息给模型让模型自己修正。这比在Python函数里到处写type(x) int靠谱得多。2.3 Memory Layer短期上下文和长期记忆为什么要分开Agent聊多轮之后最让人头疼的就是上下文膨胀。每次调用模型都要带完整历史任务一复杂历史很快就超过模型窗口。我的做法是把短期上下文和长期记忆彻底分开两个容器两套写入策略。短期上下文就是当前任务的完整消息历史深度依赖模型上下文窗口任务结束就可以丢弃。长期记忆则是把有保留价值的信息——用户的偏好、项目背景、常用的业务参数——在任务执行过程中主动提取出来写入一个独立的记忆库。每次开始新任务时从记忆库里做一次相似度检索把相关片段拼到系统提示词里让模型在“有背景”的状态下开始干活。这样既不撑爆上下文又让Agent看起来好像“记住了用户”。实现上我用了简单JSON文件加向量检索组合。向量检索负责“按语义找”比如用户说“按老规矩处理”模型能从记忆库里捞到“老规矩”的具体含义JSON文件负责“快速落地”写入和读取都简单不依赖外部数据库。需要说明的是这个方案是我基于常见实践补充的实现用于个人项目和中型团队足够了如果数据量特别大可以把这个模块替换成专业的向量数据库外部接口保持不变其他地方完全不用动。2.4 Orchestrator任务循环和退出条件是怎么设计的任务循环是整个Agent的发动机。hermes-agent每一轮都按五个阶段推进组装当前消息列表、请求模型生成结果、判断结果类型、执行工具、把工具结果回填到消息历史并继续。这里面有个容易被忽视的设计点退出条件必须明确。我设置了三个退出条件满足任何一个就立即终止——模型返回最终回答、达到最大轮数上限、用户主动中断。最初版本最大的失误就是没设最大轮数。当时跑一个数据分析任务模型反复在“读取文件——发现缺少字段——重新读取”里打转整整烧掉了几万token才停下来。后来我加了默认20轮的硬上限支持按任务配置去覆盖。这里建议所有自己写Agent的人一定要把轮数上限当成强制参数而不是可选项。它不仅是成本护栏更是防呆机制帮助你在模型异常时快速止损而不是眼睁睁看着资源被耗干。3. 实操过程从零搭建一个能跑的hermes-agent3.1 环境准备与依赖清单整个项目我用了Python 3.10开发依赖非常克制核心只有两个openai这个SDK用于调用兼容接口pyyaml用于读取配置文件。其余都是Python标准库。如果你后续要加向量检索再自己引入对应的向量库就行。我的原则是能不加依赖就不加依赖越少环境复现越简单出问题也越容易排查。安装命令很简单建一个虚拟环境然后执行python -m venv venv source venv/bin/activate pip install openai pyyaml我把模型服务地址、密钥、模型名、最大轮数这些参数都放进了config.yaml。模型这块兼容任何一个支持Function Calling的模型服务填好地址和密钥即可。顺便说一句本地跑的模型服务和云端托管服务都能接只要它们对外暴露的是兼容风格的消息请求格式。llm: base_url: http://your-model-service/v1 api_key: sk-xxx model: hermes-3 agent: max_rounds: 20 memory: backend: json file: memory.json3.2 实现Agent主循环的代码骨架主循环是整个项目的心脏。下面这段代码是agent.py的骨架去掉了一些装饰性代码保留了最核心的逻辑。核心流程非常直白先发消息看模型返回什么有工具调用就执行再回传结果继续循环直到模型给出最终回答。import json import os import yaml from openai import OpenAI with open(config.yaml, r, encodingutf-8) as f: config yaml.safe_load(f) client OpenAI( base_urlconfig[llm][base_url], api_keyconfig[llm][api_key], ) def run_agent(task: str, tools: list[dict], max_rounds: int None): max_rounds max_rounds or config[agent][max_rounds] messages [{role: user, content: task}] for round_index in range(max_rounds): response client.chat.completions.create( modelconfig[llm][model], messagesmessages, toolstools, ) message response.choices[0].message if not message.tool_calls: return message.content messages.append({ role: assistant, content: message.content, tool_calls: [ { id: tc.id, type: function, function: { name: tc.function.name, arguments: tc.function.arguments, }, } for tc in message.tool_calls ], }) for tool_call in message.tool_calls: result execute_tool( tool_call.function.name, json.loads(tool_call.function.arguments), ) messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(result, ensure_asciiFalse), }) raise RuntimeError(agent exceeded max rounds)这里有两个细节我要额外说明。第一message.tool_calls为空不代表模型一定给了最终回答有些模型服务返回的内容可能为空字符串建议做一次非空判断第二把tool_calls原样拼回消息历史时字段格式要跟协议要求完全一致尤其是tool_call_id和tool消息之间的对应关系一旦对不上模型下一轮就不知道该结果对应哪个调用。3.3 工具的注册、校验和执行逻辑工具层我用了一个注册表加装饰器的方案。每个工具函数挂上register_tool注册表里就能自动映射它的名字、描述和参数Schema。执行入口execute_tool也很简单从注册表取出工具先校验参数再执行最后返回结果。校验失败时不直接抛异常而是把错误信息当成工具结果返回给模型让它自己修正这个设计很实用。TOOL_REGISTRY {} def register_tool(name, description, parameters): def decorator(func): TOOL_REGISTRY[name] { func: func, description: description, parameters: parameters, } return func return decorator def execute_tool(name: str, arguments: dict): if name not in TOOL_REGISTRY: return {error: funknown tool: {name}} tool TOOL_REGISTRY[name] # 省略具体的JSON Schema校验代码核心是比对arguments字段 return tool[func](**arguments)写工具函数的时候我一直坚持一个原则函数本身只做一件事输入输出都尽量简单。下面是一个查询天气的工具示例实际跑的时候可以换成真实天气服务的HTTP调用这里用固定数据演示。注意它返回的并不是“查询成功”这种废话而是一个模型能直接使用的结构化JSON。register_tool( get_weather, 查询指定城市的实时天气包括温度、天气状况和风力, { type: object, properties: { city: {type: string, description: 城市名例如北京}, }, required: [city], }, ) def get_weather(city: str): # 生产环境改成 requests.get(weather_api, params{city: city}) return { city: city, temperature: 26.5, unit: celsius, condition: 晴, wind: 北风3级, }再看一个稍复杂的工具读取本地文本文件。它可以用来让Agent分析日志或者处理用户自己的数据。我把路径参数设计为必填并限制了读取大小避免一次把超大文件全塞进上下文。这个限制很重要不然一个几百MB的日志文件会直接把上下文窗口撑爆。register_tool( read_file, 读取指定路径文本文件最多返回前10000个字符适合查看日志或文档, { type: object, properties: { path: {type: string, description: 文件绝对路径}, }, required: [path], }, ) def read_file(path: str): try: with open(path, r, encodingutf-8) as f: return {content: f.read(10000)} except Exception as exc: return {error: str(exc)}3.4 简单记忆模块的实现记忆模块最开始我只做了写入和读取两个函数后端存储直接定位JSON文件。后面加了向量检索但在核心代码里我会先保证读写函数稳定再考虑检索策略。下面这段代码展示了基础环节添加记忆项、按关键词或时间范围筛选。import json import os from datetime import datetime MEMORY_FILE memory.json def load_memory(): if not os.path.exists(MEMORY_FILE): return [] with open(MEMORY_FILE, r, encodingutf-8) as f: return json.load(f) def save_memory(items): with open(MEMORY_FILE, w, encodingutf-8) as f: json.dump(items, f, ensure_asciiFalse, indent2) def add_memory(content: str): items load_memory() items.append({ content: content, ts: datetime.now().isoformat(), }) save_memory(items) def search_memory(keyword: str): items load_memory() return [item for item in items if keyword in item[content]]真实的记忆策略要比这复杂比如要决定哪些信息值得写入、哪些该丢弃、要不要做语义向量化。但从第一版开始我就建议先把“写入”和“读取”的API固定住后面替换底层实现时上层代码就不用动。这个思路跟数据库迁移是一个道理先有稳定接口再有更好的实现。3.5 跑通第一个多步任务把上面的代码串起来在main.py里写一个入口用get_weather和read_file两个工具跑一个任务。示例任务是读配置文件然后查询配置里出现城市的天气。这个过程会让模型连续调用两个工具中间经历多次模型与工具的交互可以观察整个循环是怎么一步步推进的。Tools [ { type: function, function: { name: value[description], description: value[description], parameters: value[parameters], }, } for name, value in TOOL_REGISTRY.items() ] if __name__ __main__: task 读取当前目录下的config.yaml然后查询其中city字段对应城市的天气 result run_agent(task, Tools) print(result)我实际跑这个任务时模型的行为是这样的先调用read_file读取config.yaml拿到内容后从里面识别出city: 北京这样的字段然后调用get_weather最后把两部分结果整合成一段自然语言回答。整个过程大概经历四轮模型请求两轮工具执行耗时不到十秒。第一次跑通的时候你就能理解Agent真正的价值它不是在背答案而是自己制定计划、自己调用工具、自己整合信息。4. 常见问题与排查技巧实录4.1 工具调用偶发“答非所问”根源在工具描述和返回结构我在测试中最常遇到的现象是模型明明注册了好几个工具有时候却不去调用而是直接根据自身知识编一个答案。排查下来绝大多数原因不是模型傻而是工具描述和实际需求之间对不上。比如工具描述里没写清楚“该工具可以获取实时数据如果不确定现状就调用它”模型就会觉得自己已知的信息够用直接回答了事。还有一种情况是工具返回的数据结构不规范模型拿到一堆无关字段无从下手干脆放弃工具。针对这个问题我的解决办法是在Tool Layer里加了一个“工具使用提示”模块。每次组装消息时会把任务相关的工具描述放进系统提示词并显式提示模型如果任务涉及实时性或外部数据优先使用工具核对。同时我要求所有工具返回结果必须至少包含ok和data两个字段ok表示执行是否成功data存放具体内容。这样模型拿到的结果永远有一个清晰的“读法”不会因为格式混乱而放弃使用工具。4.2 上下文溢出与Token浪费怎么裁剪消息历史Agent多轮跑下来消息列表会越来越长。尤其是每次工具返回的数据都很大时两三个任务就能把窗口顶满。我在hermes-agent里做了两层裁剪策略。第一层是针对单个工具返回值的长度限制在工具执行完后直接截断超长结果第二层是针对整体消息历史的窗口管理当历史超过预设阈值时对早期的user和assistant消息做摘要压缩把详细内容替换成一段精炼摘要。这个方案虽然简单但实际效果非常明显。以read_file为例如果读取文件返回了10000字符模型下一次请求里这条消息就会占用绝大部分窗口。我会在把工具结果回填给模型之前判断总长度超出限制就从消息列表里找到最早的非关键上下文用一段摘要替代。这里的一个心得是摘要到底该怎么生成最好是单独调用模型来做而不是直接截断字符串截断字符串会让上下文语义断裂导致模型下轮决策出错。4.3 死循环和“工具滥用”加一个行为监控器死循环是最让人头疼的问题。模型在一个任务里反复调用同一个工具每次参数还都一样比如不断重试查询一个已经被明确告知不存在的文件。这一问题在复杂推理任务中很容易出现尤其是当模型对工具返回结果不满意的时候。它往往不是继续分析而是下意识地重复调用希望下一次能换来不同结果。我在Orchestrator里加了一个“行为监控器”模块逻辑不复杂记录最近几轮的工具调用名称和参数哈希如果发现同一个工具调用链在短时间内重复出现立即中断循环并向用户反馈疑似死循环的信息。同时把这条历史拼进下一轮消息告诉模型“你已经重复调用过该工具且结果没有变化请换一种方案或直接给用户说明情况”。这个机制上线后因为我自己的Agent任务烧掉大量token的情况基本消失了。遇到类似问题不需要上很重的规则引擎一个简单的重复检测就是一个足够可靠的护栏。4.4 并发任务下的稳定性日志、重试和资源隔离当你想让Agent同时处理多个任务而不是一个个排队时稳定性问题就会集中爆发。第一个问题是模型接口的速率限制。并发上去后很容易因为请求频率过高被服务端拒绝。我的做法是在Model Layer加了一个简单的令牌桶限流器统一控制每秒最大请求数并在请求失败时做指数退避重试而不是直接报错。重试次数我设置了3次每次间隔按1秒、2秒、4秒递增实测应对偶发限流非常有效。第二个问题是任务之间互相污染。Agent的短期上下文是独立的消息列表但长期记忆是共享的。如果多个任务同时写入JSON文件就可能出现写冲突。后来我给记忆模块加了一个进程锁写入时先锁住文件再操作如果是在分布式环境跑则建议把记忆存储换成专业的KV数据库或向量数据库从根上解决并发写问题。还有一个容易被忽视的点是日志。Agent任务的每个关键步骤都要有结构化日志包含任务ID、模块名称、模型请求耗时、工具执行耗时。没有这些日志并发场景下出了问题你连复现都很难。5. 一些深水区的经验沉淀5.1 不要让Agent“一次做完所有事”拆任务才能降错率刚开始我在设计任务入口时总想用一次Agent调用来解决完整需求。实际跑下来发现任务越长、涉及工具越多模型的出错率就越高而且一旦中间一步走错后续整个链路就全白费。后来我把任务拆成多个子任务先让Agent生成一个执行计划再逐个执行。虽然多了一次模型往返但整体成功率明显上升。这也影响了整个架构设计。hermes-agent的Orchestrator里有一个“任务分派”模式当收到复杂任务时先把任务拆成步骤列表每一步作为一个独立的Agent循环上一步的输出作为下一步的输入。模型在每个小循环里的决策压力大幅降低工具调用也更精准。如果你要接的业务天然就是多步骤、多依赖的建议尽早采用这个模式别让一个循环扛太多责任。5.2 短暂的上下文快照比长期记忆更能解决“忘记”很多人一谈Agent记忆就想到长期记忆、向量数据库但我在实际落地中发现很多“忘记”根本不是跨会话导致的而是同一个会话里早期的关键信息被后来大批量工具返回冲掉了。这种情况用长期记忆去解决是牛头不对马嘴。更有效的方式是做一个“上下文快照”机制每轮模型请求之前把用户原始任务、最近一次决策和当前待完成目标单独拎出来放在消息列表最前面。这种快照的成本极低但对模型保持任务方向非常有效。它的本质是在给模型提供一个“任务锚点”防止模型在工具调用链里被带偏。我在跑多工具长任务时发现只要把任务目标反复强调模型中途迷路的概率就会大幅下降。这个经验你可能在官方文档里看不到但非常值得一试。说到底Agent的稳定性很多时候不取决于模型的聪明程度而取决于你给它看的上下文质量。5.3 工具返回前先“洗数据”能省掉一半调试时间工具返回的数据清洗是提升整个Agent稳定性的杠杆点。最开始我自己写工具时经常图方便直接返回原始数据比如把数据库查询结果原样返回把HTTP响应直接返回。结果模型在处理这些原始数据时经常出现误用比如把字段名猜错、把单位弄错、把时间格式看错。后来我形成了一个习惯工具在执行完业务逻辑后专门加一步“提纯”——把模型需要的关键字段抽出来重命名成通俗易懂的字段并统一格式。这个过程不复杂但效果极其显著。比如一个查询订单状态的工具数据库返回的是status_code2这种编码我会在工具里翻译成status已发货再返回一个时间字段我会确保返回2025-01-01 10:00:00这种标准格式绝不让模型去猜。这样做之后模型下一轮的回答准确度高了很多调试时间直线下降。让模型少做一点隐式推断Agent的可靠性就多一分。5.4 给Agent设计“后退一步”的能力还有一个容易被低估的机制让Agent在遇到意外情况时能够主动“后退一步”而不是硬着头皮继续。我在Agent循环里给了模型一个名为report_issue的内部工具它只有一个参数——问题描述。当模型发现工具参数无论如何都凑不齐、数据矛盾无法自行解决、任务需求过于模糊时它可以主动调用这个工具把当前情况和卡点反馈给上层。上层收到report_issue调用后会终止当前任务的自动推进转交给人工或预设的处理流程。这个机制相当于给Agent加了一个“举手”功能。很多Agent跑崩不是因为模型不聪明而是因为它缺乏表达卡点的能力只能硬编一个结果或者陷入死循环。report_issue让模型在不确定的时候敢于承认不确定这对于自托管Agent这种没有人在旁边看着的场景尤其重要。实际用下来它帮我拦下了大量错误的自动决策。5.5 指标和观测是Agent工程化绕不开的一课最后一个心得是把Agent当成一个正式的服务去观测而不是当成一个脚本。上线之前我给hermes-agent补了一套基础的监控指标每轮模型调用的耗时和token数、每个工具的调用次数和失败率、每次Agent任务的总耗时和最终结果。这些指标不需要多复杂记录到结构化日志里就行。它带来的直接好处是你可以迅速知道是模型接口变慢了还是工具执行变慢了还是模型在某一个工具上反复打转。没有这套观测Agent开发就始终处于一种“玄学调试”的状态。你只知道它有时好有时坏但说不清哪里好哪里坏。有了指标你才能在上线之后持续迭代。我甚至会在每次任务结束前把整条工具调用链和模型的中间决策打成一个JSON存下来后面出问题的时候直接离线回放。这套做法几乎不需要额外基础设施一个日志文件就能搞定但对稳定性的提升却是实打实的。我在实际开发中最深的体会是Agent项目的难点从来不是“让模型调用工具”这个技术点而是如何在一个有噪声、有错误、有延迟的真实环境里把这一条循环做得足够稳定、可控、可观测。hermes-agent走到今天让我最满意的地方不是某个功能有多炫酷而是它现在遇到任何异常情况基本都有明确的行为路径——要么完成任务要么明确告诉你它卡在哪了而不是自己在那里无意义地空转。如果你也在做类似的Agent希望这篇文章能帮你在同样的坑前面少踩几次。
返回列表