news 2026/9/8 12:08:08

从零构建AI Agent框架:消息循环、工具调用与自动化避坑实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零构建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_steps=10): 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( name="search_knowledge_base", description="Search 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_cleaner

temperature我特意调低了。agent任务和聊天不一样,我们需要的是稳定且可预期的工具调用格式,不是天马行空的创意文案。设到0.2左右,模型输出的JSON格式规范率明显更高,重复执行的结果差异也小很多。

3.2 定义第一个工具:一个简单的文件操作器

装完依赖,我做的第一件事不是搭主循环,而是先定义两个工具。一个读文件,一个写文件。理由很简单:有了这两个,agent就能自动完成"读配置-修改-写回"这类最常见的本地自动化任务。

@tool( name="read_file", description="Read 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", encoding="utf-8") as f: return f.read() @tool( name="write_file", description="Write 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", encoding="utf-8") as f: f.write(content) return f"File 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.2s
claude-3.5-sonnet90%4.37.8s
qwen2.5-14b (本地)70%5.612.4s

gpt-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": f"Invalid 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这个信使角色的核心能力,从来不是自己会飞,而是每个任务都靠谱地送到了正确的门。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 12:07:06

登录测试全攻略:从功能到安全的系统化用例设计实战

1. 登录测试:看起来简单,坑起来要命 登录功能大概是所有软件测试工程师入行后接触最多的模块,也是最容易被低估的模块。刚入行的时候,我也觉得登录嘛,不就是输入用户名密码、点个按钮、跳个页面,能有什么花…

作者头像 李华
网站建设 2026/9/8 12:05:46

基于OpenTK的WinForms 3D图表控件:从渲染到颜色替换

简介:面向 Windows Forms 开发者的 C# 3D 图表控件资源,基于 OpenTK 调用 OpenGL 进行三维渲染,在窗体中以立体图表展示多维数据,解决普通二维图表难以呈现多维度关系的问题;控件支持图表颜色和文字颜色替换&#xff0…

作者头像 李华
网站建设 2026/9/8 12:05:43

C#上位机集成SharpPcap:Modbus TCP抓包解析与自动化联调实战

简介:这是一份面向C#开发者与网络运维人员的网络抓包工具包,基于SharpPcap开源库实现数据包的捕获与分析,可用于网络故障诊断、实时流量监控、安全威胁排查等场景。资源共17个文件,压缩包大小2.24MB,内容包含可直接运行…

作者头像 李华
网站建设 2026/9/8 12:05:17

Claude Code插件精选:9款真正提升编码效率的生产力工具

我见过太多人把 Claude Code 装成了一锅八宝粥,什么插件都想塞进去,最后 Agent 还没开始干活,光加载插件就卡半天,上下文窗口被各种无用指令塞满,每次对话烧掉的 token 比代码还多。2026 年早就不是“装得越多越厉害”…

作者头像 李华
网站建设 2026/9/8 12:04:52

Agent Skills 全景解析:从工具调用到生产级工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 12:04:29

用友NC自由报表报错“用户没有组织权限”的排查与解决指南

1. 报错本质:你踩中的不是SQL问题,是NC的权限模型做用友NC报表的兄弟,十有八九都见过这句话:“查询数据出错,用户没有组织权限”。第一次遇到,我差点把报表的SQL翻了底朝天,又是看表关联又是查过…

作者头像 李华