从零构建AI Agent框架:消息循环、工具调用与自动化避坑实战
这段时间我在做一个内部工具起了个名字叫 hermes-agent灵感来自希腊神话里的信使赫尔墨斯——这玩意儿在我这边的角色也差不多专门负责把各种散落的自动化任务串联起来传递给该干活的地方。项目本身不复杂但拆开讲的话里面涉及的东西还挺碎模型调用、工具注册、任务规划、状态记忆、异常重试……一整套东西叠在一起就成了一个可以反复用的agent框架。如果你也正在琢磨怎么把自己的脚本、API或手工操作沉淀成一个半自主的智能体或者你已经在用LangChain、AutoGPT那类框架但觉得太重、想自己从零控一遍细节那这篇应该能给你一点参考。我不会讲什么大而全的架构原理就把自己从零开始写、跑、翻车的整个路子按真实的踩坑顺序捋一遍。1. 为什么需要hermes-agent脚本自动化的最后一公里很多人可能会问Python脚本加crontab不是早就解决自动化了吗为什么还要再折腾一个所谓的agent框架。这个问题的答案恰恰是我写hermes-agent的起点。1.1 从定时脚本到智能代理的演进痛点我之前的自动化方式非常朴素写一个函数读某个输入处理一下输出一个结果然后扔给cron定时跑。这套路在任务固定、输入格式不变、流程完全确定的时候效率很高但只要稍微加一点变化麻烦就来了。比如我有个需求每天早上抓取几个信息源的数据按热度排序挑出跟某个主题相关的部分整理成摘要发出去。这里面的每一步单看都能写死但合在一起就很痛苦——相关怎么定义热度怎么排序万一某个信息源今天挂了是跳过还是重试输入一变脚本就得改逻辑。我改了三轮之后意识到我需要的不是一个更长的脚本而是一个能接收自然语言指令、自己拆解步骤、调用现有工具的调度层。这就是hermes-agent最早的雏形。1.2 名字背后信使型agent的设计隐喻Hermes在神话里不只是跑腿送信他还负责引导灵魂、传递神谕甚至在各种任务之间充当协调角色。我设计这个agent的定位也是一样它不是干具体活的它是理解意图、规划路径、指挥工具干活的那个中间层。这个定位意味着几件事。第一agent要足够轻不能绑死任何一家模型厂商的SDK第二agent需要一套清晰的工具注册机制让外部能力能像插件一样往里插第三agent需要在每一步决策时能感知当前状态而不是从头到尾只会傻执行预定义流程。说白了我把agent当成一个路由中枢模型负责思考工具负责执行agent本体只负责把两者粘起来。1.3 项目目标圈定不做大而全只做可靠调度开始写代码之前我给自己定了几条铁律。不加向量数据库不做长期记忆至少第一版不做不做多agent协商机制这些是第二期的事。第一版只解决三个问题让LLM能理解任务、能把任务拆成步骤、每一步能正确调用我注册好的Python函数。把一个复杂问题限制在一个清晰的边界里这很重要。因为LLM本身不可控如果架构再复杂出了问题你根本分不清是模型的锅还是代码的锅。我见过不少人一上来就上全套RAG加记忆加多代理最后项目烂尾的。我自己也烂过一次尾所以这回学乖了——先把主链路跑通再谈花活。2. 核心架构拆解消息循环、工具注册与记忆分层的协作逻辑hermes-agent的结构设计我参考了OpenAI函数调用和早期AutoGPT的实现思路但做了很多简化。整体可以理解成一个while循环套状态机每次循环里模型看一遍当前消息和历史决定下一步做什么。2.1 消息循环LLM如何一步步推进任务核心循环长这样把用户任务转成一条system message加一条user message塞给模型拿到一个响应。响应如果是普通文本说明任务完成把结果返回给用户响应如果是一个工具调用请求就执行对应工具拿到结果后再作为一条新消息追加进对话继续让模型进行下一步判断。这里有个很关键的细节工具执行结果必须作为消息回到对话里而不是在代码里直接拼进下一个prompt。这样做的目的是让模型能看到我上一步做完之后发生了什么它才能决定下一步怎么调整。我把这个设计叫可读环回每一条工具结果都是模型可观察的状态。def run_agent(task, max_steps10): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: task} ] for step in range(max_steps): response llm.chat(messages) if response.tool_calls: messages.append(response.message) for call in response.tool_calls: result execute_tool(call.name, call.arguments) messages.append({ role: tool, tool_call_id: call.id, content: result }) else: return response.content raise TimeoutError(max steps exceeded)max_steps这个参数我默认设成10实际用下来大部分任务在4到6步内就能完成。设置上限是硬保障防止模型在某个分支里无限绕圈这也是LLM应用里必须有的逃生舱。2.2 工具注册与权限边界工具是agent的双手注册机制决定agent能用什么、不能用什么。我的实现很简单用装饰器把Python函数暴露给模型tool( namesearch_knowledge_base, descriptionSearch the internal knowledge base by keyword, returns top 5 related articles., parameters{ keyword: {type: string, description: The search keyword}, limit: {type: integer, description: Max results, default 5} } ) def search_knowledge_base(keyword: str, limit: int 5): return kb.search(keyword, limit)这里面的门道在description的写法。模型是靠函数名和描述来决定什么时候调用、传什么参数的所以description必须写清楚这个函数什么时候该用、什么时候不该用。比如一个发邮件的函数描述里就得说明仅用于最终报告发送不要在整理过程中调用否则模型可能刚生成一半就把草稿发出去了。权限边界方面我建议把危险操作删除文件、执行shell命令、发消息默认关掉需要时通过环境变量或配置文件显式开启。我用了一个简单的level字段0代表只读工具1代表写入但可撤销2代表不可撤销操作。在跑批量任务时默认只挂载level 0和1的工具。2.3 记忆分层短期上下文与长期存储很多教程一讲agent就必提向量数据库但实际场景里真正缺的不一定是找得回来而是记得住正在干什么。我把记忆分成了两层。短期记忆就是对话上下文本身把每一步的工具调用和结果都保留在messages数组里长度控制在模型context window的一半以内超出就做摘要折叠。这个折叠策略我用了一个土办法每满N轮让模型把前面的对话压成一段摘要替换掉旧消息。虽然简单但实测比什么map-reduce都好使。长期记忆第一版没做持久化因为我发现大部分任务是一次性的做完就完长期记忆反而可能带偏后续无关任务。后来加了一个很轻的SQLite表只记录任务类型、关键参数和最终结果启动时把相关历史作为参考示例放进system prompt。效果挺好但对每一条历史我都加了时间戳防止模型把旧信息当最新状态。3. 环境搭建到第一个Agent跑通理论说完了上实操。这部分我尽量按我实际启动项目时的顺序写包括装了什么依赖、配了哪些参数、第一版长什么样、跑第一个任务的时候输出是什么。3.1 依赖安装与配置项说明我的技术栈是Python 3.11加openai SDK但为了不被厂商绑定我外面套了一层用litellm做的统一调用接口。litellm这东西的好处是一个函数切几十家模型服务商坏处是某些模型的tool calling格式兼容性有差异后面实战环节我会专门讲。pip install litellm pydantic pyyaml rich配置文件我用YAML结构大概长这样model: provider: openai name: gpt-4o-mini temperature: 0.2 max_tokens: 4096 agent: system_prompt_path: ./prompts/system.txt max_steps: 10 default_timeout: 30 tools: enabled: - file_reader - web_search - calculator disabled: - shell_executor # level 2 tools require explicit allow allowed_level_2: - data_cleanertemperature我特意调低了。agent任务和聊天不一样我们需要的是稳定且可预期的工具调用格式不是天马行空的创意文案。设到0.2左右模型输出的JSON格式规范率明显更高重复执行的结果差异也小很多。3.2 定义第一个工具一个简单的文件操作器装完依赖我做的第一件事不是搭主循环而是先定义两个工具。一个读文件一个写文件。理由很简单有了这两个agent就能自动完成读配置-修改-写回这类最常见的本地自动化任务。tool( nameread_file, descriptionRead content from a file. Use this when you need to inspect the input data or configuration., parameters{ file_path: {type: string, description: Absolute path to the file} } ) def read_file(file_path: str) - str: with open(file_path, r, encodingutf-8) as f: return f.read() tool( namewrite_file, descriptionWrite content to a file. Creates the file if not exists, overwrites if exists. Only use this when explicitly confirmed., parameters{ file_path: {type: string, description: Absolute path to the file}, content: {type: string, description: Content to write} } ) def write_file(file_path: str, content: str) - str: with open(file_path, w, encodingutf-8) as f: f.write(content) return fFile written: {file_path}注意read_file我直接返回全量内容但写工具注册这块有个限制如果文件太大动辄几十万token直接塞进上下文会把模型冲晕。所以我建议真实场景里给read_file加一个max_chars参数默认3000超出部分截断并返回文件总行数让模型知道信息不完整可以根据需要按行号范围再读。3.3 多步骤任务实测让agent自动整理报告工具准备好之后我运行了第一个真正意义上的综合任务。任务描述是这样的读一下reports目录下最近的销售周报提取各地区的销售额变化计算环比增长率然后生成一个Markdown摘要写到summary目录。这个任务说难不难但需要模型理解最近是什么意思——它得先列目录、按文件名排序找最新的需要它能读取文件、提取数字、做计算还需要它在最后把结果整合成一份新文件。整个任务跨了读、算、写三类工具是测试agent能力的好case。第一轮跑的时候我预期的执行路径是list_directory确认文件清单read_file读最新文件为了保险还可能再读一个上周的做对比最后write_file写摘要。实际跑下来确实走了这个路径一共用了5步。中间有一个小插曲模型在计算环比时没有直接用工具而是自己在回复里推了个公式结果算错了。我后来在system prompt里加了一句所有数值计算必须用calculator工具完成禁止心算才把这个毛病纠正过来。这轮测试暴露出的最大教训是prompt工程很大程度是在跟模型的盲目自信做斗争。模型在文字推理上很强但数字计算几乎必然翻车你不能指望它大概算一下必须用规则把它的行为锁在工具调用上。4. 实测中的数据、异常与参数调优主链路跑通之后我开始批量测试拿不同的模型、不同的任务跑了大概两百多次积累了一些比较有参考价值的实测数据。这个环节的结论可能跟你在官方文档里看到的不太一样但都是真实跑出来的。4.1 不同模型在同样的任务上的表现对比我测试的三个模型是gpt-4o-mini、claude-3.5-sonnet和本地部署的qwen2.5-14b。测试任务统一是读取sales_report_202501.csv统计每类商品的总销售额找出最高的三类写入result.txt共跑20次统计成功率、平均步数和平均耗时。模型成功率平均步数平均耗时gpt-4o-mini95%4.16.2sclaude-3.5-sonnet90%4.37.8sqwen2.5-14b (本地)70%5.612.4sgpt-4o-mini胜在工具调用格式稳定20次里只有1次把JSON参数写坏claude在复杂推理上更强但偶尔会在调用工具之后自作主张多加一句解释文本导致消息结构解析失败qwen本地版成功率偏低主要不是模型笨而是工具调用的系统提示词格式跟OpenAI不完全一致需要单独适配。这个对比给我的结论是如果你只用一个模型优先选工具调用协议最稳的那个而不是推理能力最强的那个。因为agent场景下大部分任务轮不到模型展示推理深度反而格式错误会导致整个链路崩溃。4.2 最容易翻车的环节工具参数解析翻车率的统计我另外做了记录在20次失败里有13次死在参数解析上。模型还了JSON但是少传一个必填参数或者参数名拼错或者把字符串传给了整数类型字段。这些错误在单次调用里不容易发现但在多步任务里一旦出现整个agent会话就卡住了。对此我的解决策略是三层防护。第一层是JSON解析容错别用json.loads硬解析写一个lenient_json_parse函数能处理尾部逗号、单引号、漏掉引号等常见问题。第二层是参数类型校验从parsed dict里取字段时用Pydantic的validator做类型转换int字段遇到字符串5要能自动转bool字段遇到true和false字符串要能处理。第三层是当校验失败时把错误信息返回给模型让它自己修正。def safe_execute_tool(registry, name, raw_arguments): try: parsed lenient_json_parse(raw_arguments) return registry.call(name, parsed) except ValidationError as e: # feed this back to the model so it can fix its own call return json.dumps({error: fInvalid parameters: {str(e)}})这个把错误喂回模型的思路是整篇文章里我认为最值得抄的一个技巧。你不需要在代码里把所有异常情况都兜住只需要把异常翻译成模型能理解的语言扔回去它会自己改。4.3 调优经验temperature、max_steps、重试策略参数调优方面我分享几组实测下来的经验值不一定适合所有人但可以作为起点。temperature设置在0到0.3之间。我默认0.2处理代码生成类任务降到0处理需要少量创造性表达的摘要类任务可以升到0.5但再高就会出现格式不稳定。max_steps看任务复杂度。简单的读文件-汇总-写文件三步任务设6就够涉及多层数据筛选、多轮工具联动的设10到15。设置太高有个隐患模型发现步骤用完还没完成任务时会开始反复调用工具做无用功生成大量垃圾中间步骤浪费token。重试策略上我只对网络错误和限流错误做自动重试而且最多重试3次指数退避起步间隔1秒对工具执行返回业务错误不重试因为重试大概率复现同样结果应该让模型看错误信息自己调整。自动重试真正要防的是瞬时故障不是逻辑错误。5. 避坑实录从原型到可用我踩过的几个关键问题从能跑到跑得稳之间隔着一大堆细节问题。这些问题不踩一遍光看设计文档真的想象不到我把其中最有代表性的几个写在这里算是给后来人探个路。5.1 模型幻觉与工具调用冲突的处理有一次我让agent做一个数据清洗任务工具返回的结果里明确写着文件内未发现任何空值行但agent在下一步的回复里偏偏说已删除13行空值数据并且郑重其事地报告任务完成。问题源头在于模型输出时自由发挥了它认为应该有空值于是生成了符合预期的叙事而不是严格基于工具结果。这类幻觉在Agent里会引发连锁反应它会继续基于虚构的结果做下一步决策比如虚构一个不存在的Key、调用有一个不存在参数的工具一步步把结果带偏。核心防护是两条。第一system prompt强制约束你只能陈述工具返回的真实数据禁止推测。如果工具执行结果跟你预期不符必须明确指出差异。第二在代码层做产物校验比如删除类操作执行后让工具返回操作影响的行数agent下一步决策必须引用这个数字。5.2 并发与超时控制本地串行跑的时候很稳但一旦挂上HTTP接口多个用户同时发起agent任务问题就来了。最典型的是同一个工具同时被多个agent会话调用比如两个任务同时写一个状态文件互相覆盖。我后来做了两个层面的控制。agent会话层面所有会话还没用一个全局锁而是每个任务绑定一个task_id写文件的函数会在文件名后面带task_id前缀任务结束后归并这样天然隔离不需要锁。在单会话内部我给每个工具调用加了超时机制用concurrent.futures包装一下超过timeout直接返回task timeout给模型让模型决定是重试还是换路径。5.3 日志与可观测性设计Agent应用和普通接口不一样普通接口你打几行日志能看到请求参数和返回结构错误定位很快但agent是多步决策跑完一次任务可能生成了几十条上下文消息没有体系化的日志根本无从排查。我建了一套日志规范每次任务记录一个task_id以下关键节点都要打点收到任务的原始输入、每一步模型返回的完整内容包括tool_calls结构、每个工具的入参和出参摘要、每轮循环的token消耗以及最终输出。这些日志同时落到本地文件和显示面板排查问题时直接按task_id过滤。还有个更实用的技巧把失败任务的messages数组完整dump下来存成JSON文件复现时直接喂给模型接着跑。这相当于给agent拍X光片每一步它看到什么、做了什么决策一目了然。这个能力在我后期优化prompt时帮了大忙。6. 从单Agent到多Agent协作的扩展与后续规划主链路稳定之后我自然开始琢磨一条更复杂的方向——让agent具备子agent的能力。目前这套架构是单任务单会话但对于某些需要并行收集信息的场景效率还不够。下一步我打算把任务规划层嵌一套动态拆分逻辑让agent在不同任务子域之间并行铺开这大概是多agent协作的实际雏形了。6.1 多agent协作的实际做法多agent最少可行的做法大概是在现在工具清单里增加一类agent_launch工具。这个工具接收子任务描述、子目标、输出格式然后拉起一个新的agent会话去执行执行完把结果作为工具返回值拿回来。这种做法的好处是大幅省上下文内存——子agent产生的中间日志不会污染主角的对话上下文只有终点结果会回到父会话里。就好比之前写的那个报告任务后续升级版会变成父agent负责拆出品牌A数据收集品牌B数据收集汇总对比三个子任务分别拉三个子agent并行去跑最后收集回来做汇总。从架构上看这是最自然的演变也能复用现有全部工具注册机制。6.2 一些开发中累积的个人体会最后聊点虚的开发这种agent最容易被低估的成本其实不在代码而在让LLM按预期行为工作。写工具注册、消息循环这些一两天就能搞定但调prompt、修参数解析、处理各种边缘案例这些琐碎的优化分散在每一轮的测试里没有止境。我现在每次发布新版本前都要拿过去两周的失败case重建一个回归测试集确保修复A问题不会又把B弄坏。还有一点体会比较深的是千万不要迷信智能体自主完成任务这件事。现阶段合理的期望是agent能把80%的常规操作自动做对剩下20%的模糊地带它要能识别出不知道该选哪条路并且主动来问你把决策权交回给用户这比头铁往前冲走错路要强得多。我在设计里专门加了一个clarify工具模型拿不准时可以直接触发一个交互——看起来不起眼但这个设计让整个系统的可用性上了一个台阶。如果你也要给自己搭一套agent我最后想啰嗦两句先把工具的边界画清楚比先把智能做得更聪明重要先把错误处理做扎实比先把流程做得更花哨重要。Hermes这个信使角色的核心能力从来不是自己会飞而是每个任务都靠谱地送到了正确的门。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →