ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

轻量级智能体框架实战拆解:从零构建可控的AI Agent核心循环

2026/9/8 12:44:36 拓冰建站 浏览量
轻量级智能体框架实战拆解:从零构建可控的AI Agent核心循环 不知道你有没有遇到过这种情况项目里想接入一个能自主规划、调用工具的AI助手翻了半天开源框架要么太重、依赖一堆用不上的东西要么太黑盒、出了问题根本不知道它内部在干什么。我前段时间做的这个 hermes-agent 就是为了解决这个痛点而生的一个轻量级、可控性强的智能体框架核心。它不追求大而全而是把 Agent 最核心的“感知-决策-执行”闭环拆开揉碎让你既能快速上手也能按需改造成自己团队需要的形态。如果你正准备在业务里落地 AI 代理或者想搞清楚“Agent 到底是怎么跑起来的”这篇实战拆解值得你花十分钟看完。1. 项目整体设计与核心思路拆解1.1 为什么不自接套用现成框架而是选择自研核心先说说背景。我最早做 Agent 相关的功能时也图省事直接上了社区里比较火的几个编排框架。用下来发现几个很别扭的地方第一框架抽象层级太多一个简单的“用户问一句话 - 模型决定调哪个工具”的流程中间隔了好几层回调、缓存、记忆组件排查问题时链路长到怀疑人生第二很多框架为了适配所有场景把配置项做得极其复杂一个 demo 跑通要写几百行样板代码而真正业务里我们只用其中 20% 的能力第三也是最重要的一点——模型输出解析、工具调用协议、上下文管理这几个关键节点框架封得太死想针对自己的业务做定制得去改框架源码维护成本非常高。所以 hermes-agent 从设计第一天就定了一个规矩核心循环自己写外部依赖能省则省。它本质上就是一个“调度器”把大模型、工具函数、记忆存储这三样东西以最直白的方式串起来。你在代码里能清楚看到每一次模型调用、每一个工具执行的完整轨迹出了问题直接看日志就能定位。这种“可控性优先”的设计思路对于中小团队或者个人开发者来说往往比盲目引入重型框架更实用。1.2 整体架构信使模式的角色定位项目取名 Hermes其实就是希腊神话里负责传递信息的信使神这个寓意很贴合 Agent 的本质——它不是一个“什么都懂”的知识库而是一个“把用户意图转译成工具动作再把执行结果翻译回用户语言”的信使。基于这个定位hermes-agent 的架构拆成四个层级交互层负责接收用户指令输出最终回复不做任何逻辑处理。调度层核心维护对话状态执行“模型决策 - 工具调用 - 结果回填”的循环直到模型认为任务完成。工具层以函数注册表的形式挂载各种能力比如查天气、查数据库、调内部 API工具与工具之间互相独立。记忆层存放短期对话上下文和长期向量记忆供调度层在生成决策时检索。这个分层的好处是每一层都可以独立替换。比如今天用的模型是 GPT 系列明天想换国产模型只需要改交互层和调度层里模型调用的适配器工具层和记忆层完全不用动。后面我会详细讲每一层的实现细节。2. 核心模块解析与关键技术选型2.1 Agent 核心循环理解“感知-决策-执行-再感知”Agent 和普通 Chatbot 最大的区别在于它不是“一问一答”就结束而是可能为了完成一个任务连续做多次推理和工具调用。hermes-agent 的核心循环用伪代码表达其实非常简单while not task_finished: 把当前对话历史 工具描述列表一起发给大模型 模型返回结果可能是最终回答也可能是一个工具调用指令 如果是最终回答返回给用户流程结束 如果是工具调用指令 解析出工具名和参数 在注册表里找到对应函数并执行 把执行结果以“系统消息”的形式追加到对话历史 回到循环开头让模型看到工具结果后继续决策我在设计这个循环时踩过一个坑最开始我把“最大循环次数”设得太大结果模型在一个失败的工具调用上反复重试白白浪费了很多 token。后来我加了两个机制来解决一是默认最大迭代次数设为 8 次超过就直接终止并提示用户任务太复杂二是每次工具调用失败时把错误信息原样返给模型让它自己判断是修正参数重试还是换一种方案而不是硬编码重试策略。这相当于把“柔性容错”交给了模型效果比写死一堆异常分支好得多。注意核心循环里最容易忽略的是“工具结果怎么回填”。我见过一些实现把工具结果当成普通用户消息塞回去这样模型很容易混淆哪些是用户说的、哪些是工具返回的。正确的做法是显式用 system 或者独立的 tool role 标记告诉模型“这不是用户在说话这是工具执行的客观结果”。hermes-agent 在构造消息列表时严格区分了 user、assistant、tool 三种角色这个细节后续在排查问题时会省很多力气。2.2 工具调用协议定义函数描述与参数校验工具层是 hermes-agent 的重头戏。模型本身不会去执行你的 Python 函数它只是根据你的描述“决定”该调用哪个工具、传什么参数真正的执行动作发生在你的代码里。所以“函数描述”写得好不好直接决定了模型调用的准确率。我这边的做法是一套轻量的描述协议每个工具用一个 JSON 片段描述{ name: get_weather, description: 查询指定城市的实时天气情况包括温度、湿度、天气现象, parameters: { type: object, properties: { city: { type: string, description: 城市中文名例如北京、上海 } }, required: [city] } }这段描述在每次循环时都会随用户消息一起发给模型。描述里最关键的两个字段是description和parameters.properties[].description模型主要靠它们理解“这个工具是干什么的、参数该怎么填”。我实测下来description 写得越具体、越贴近口语模型传参的准确率越高。比如你把city描述成“城市名”而不给例子模型有时会传拼音、有时会传带“市”字你给了“北京、上海”这种示例之后错误率明显下降。参数校验方面我没有盲目引入 JSON Schema 校验库而是在工具函数外层包了一层装饰器用 Python 的inspect.signature自动解析函数入参并和模型返回的参数做比对。遇到缺失必填参数或类型不匹配时不直接抛异常而是生成一条结构化的错误消息返回给模型让它自己修正。这样处理的好处是模型在下一轮决策时能“看见”自己刚才的失误表现出类似自我纠错的行为整体任务成功率比直接硬校验高不少。2.3 记忆系统短期上下文与长期记忆如何配合Agent 的记忆不能一把梭。hermes-agent 里我分了两层短期记忆就是一个固定长度的消息队列保存最近 N 轮对话和工具调用记录。N 的取值要根据模型上下文窗口动态算比如模型支持 128k 上下文我会把它按比例折算成 3 万左右的中文字符中文字符按 2 个 token 算保守估计超出部分用简单的“丢弃最早的非关键消息”策略裁剪。长期记忆基于 embedding 的向量检索。每次对话结束后把用户的核心诉求和最终结论抽取出来写入一个本地向量库我这边用的是轻量的 SQLite 维度不高的 embedding 模型。当新对话开始时先把用户当前的输入向量化从记忆库里检索 Top 3 相关历史结论作为背景提示注入系统提示词里。这个设计借鉴了一个很朴素的生活经验短期记忆负责“手头这件事别搞乱”长期记忆负责“以前的经验别白费”。两层记忆的读写粒度不同短期记忆是全量参与推理长期记忆是压缩后按需注入二者互相配合既控制 token 消耗又不丢失关键的项目背景信息。3. 实操过程从零搭建一个可以跑起来的 Agent3.1 环境准备与项目目录规划为了让这篇文章里的示例真的能复现我特意把 hermes-agent 做成一个纯 Python 项目依赖极简核心只需要openai兼容任意 OpenAI 格式的接口、tiktoken做 token 统计、numpy处理向量相似度这三个库。你本地只要有一个 Python 3.9 以上环境就能跑通。项目目录我建议按这样组织hermes-agent/ ├── hermes/ │ ├── __init__.py # 导出 Agent 类 │ ├── core.py # 核心调度循环 │ ├── tools.py # 工具注册装饰器 │ ├── memory.py # 短期/长期记忆实现 │ └── llm.py # 模型调用适配层 ├── examples/ │ └── weather_assistant.py # 示例天气查询助手 ├── requirements.txt └── README.md我不喜欢把项目拆成一个巨大的包几百个文件那种结构对中小项目来说就是灾难。按“核心调度、工具、记忆、模型适配”四个维度拆文件足够清晰了。工具注册用装饰器的方式新增工具只需要写一个普通函数再标注一下不用去改调度代码这对后续同事接入自己的业务 API 非常友好。3.2 核心调度循环代码逐段解析先看llm.py模型适配层核心是屏蔽不同模型提供方的差异。我这边在封装的接口上留了base_url和api_key两个配置这样你可以接任何 OpenAI 兼容的服务一键切换import os from openai import OpenAI class LLMClient: def __init__(self, model: str gpt-4o-mini, base_url: str | None None): self.model model self.client OpenAI( api_keyos.getenv(HERMES_API_KEY, your-key), base_urlbase_url, # 不传则使用默认地址 ) def chat(self, messages, toolsNone): resp self.client.chat.completions.create( modelself.model, messagesmessages, toolstools, # 工具描述列表无工具时传 None tool_choiceauto, # 让模型自主决定是否调用工具 ) return resp.choices[0].message然后是core.py也就是整个 Agent 的心跳。我贴一下核心循环的关键代码并对每段做注释说明class HermesAgent: def __init__(self, llm: LLMClient, max_iterations: int 8): self.llm llm self.max_iterations max_iterations self.tools {} # name - function self.tool_descs [] # name - JSON Schema def register_tool(self, name, description, params_schema): self.tool_descs.append({ type: function, function: { name: name, description: description, parameters: params_schema, }, }) # 注意这里暂时省略了真正的函数绑定逻辑 # 实际实现里还需要一个字典把 name 映射到可调用对象 def run(self, user_input: str, history: list | None None) - str: messages history or [] messages.append({role: user, content: user_input}) for step in range(self.max_iterations): # 关键点1每次循环都把完整对话历史工具描述发给模型 assistant_msg self.llm.chat(messages, toolsself.tool_descs) # 关键点2判断模型是想直接回答还是想调用工具 if not assistant_msg.tool_calls: messages.append({role: assistant, content: assistant_msg.content}) return assistant_msg.content, messages # 关键点3把模型的工具调用意图追加进历史 messages.append(assistant_msg) # 关键点4逐个执行工具调用 for call in assistant_msg.tool_calls: fn_name call.function.name fn_args json.loads(call.function.arguments) try: result self.tools[fn_name](**fn_args) content json.dumps(result, ensure_asciiFalse) except Exception as e: # 关键点5执行失败也不中断把错误信息返回给模型自我纠错 content json.dumps({error: str(e)}, ensure_asciiFalse) messages.append({ role: tool, tool_call_id: call.id, content: content, }) return 抱歉任务超出了我单次处理的复杂度上限请尝试简化需求后重试。, messages这段代码里有几个设计取舍我单独说一下。第一tool_choiceauto是刻意为之。有段时间我图省事直接设成required想让模型每次都走工具调用流程结果在不需要工具的闲聊场景里模型开始瞎编工具调用行为和精度反而更差。auto让模型自己判断该不该用工具整体体验最自然。第二工具调用结果统一json.dumps序列化。不管你的函数返回的是字符串、字典还是列表先转成 JSON 再塞回对话历史这样模型拿到的是结构化数据它更容易从中提取关键信息来组织最终回复。如果你直接str()一个 Python 对象常常会带一些单引号、None 之类的非 JSON 内容模型的解析能力会被白白浪费一部分。第三异常捕获返回错误信息这个思路其实是从“让模型自己反思”这个方向上延伸出来的。一开始我也想过在工具执行失败时直接中断整个循环、给用户报错但后来发现很多失败只是参数稍微偏差让模型看着错误信息重新规划一次成功率能提升两三成。当然这也要配上最大迭代次数的保护不然模型可能在同一个错误上转圈。3.3 定义与注册工具的完整示例示例场景我选了“个人日程助手”它有两个工具一个是查询当前时间一个是把一段文本写入本地的备忘文件。这两个工具都很简单但刚好能覆盖“无参数调用”和“带参数调用”两种常见形态from datetime import datetime from pathlib import Path def get_current_time(): 获取当前本地时间格式为标准可读字符串。 return {current_time: datetime.now().strftime(%Y-%m-%d %H:%M:%S)} def append_to_memo(content: str): 把一段文本追加到本地 memo.txt 文件末尾。 memo_path Path(memo.txt) with memo_path.open(a, encodingutf-8) as f: f.write(content \n) return {status: success, path: str(memo_path.resolve())} # 对应的 schema 描述注册时传给 agent time_schema { type: object, properties: {}, required: [], } memo_schema { type: object, properties: { content: {type: string, description: 要记录到备忘文件里的文本内容} }, required: [content], } agent HermesAgent(llmLLMClient()) agent.register_tool(get_current_time, 获取当前本地时间返回日期和具体时刻, time_schema) agent.register_tool(append_to_memo, 把用户指定的内容追加写入到本地备忘文件, memo_schema)这里有一个新手很容易踩的坑工具描述里的“这个工具是干嘛的”一定要站在“模型需要怎么用它”的角度去写而不是站在“我这个函数内部做了什么”的角度。比如旧版本的描述我写的是“执行 append 操作向文件写入文本”模型在遇到“我想记一下明天开会”这种需求时就不太会联想到这个工具改成“把用户指定的内容追加写入到本地备忘文件”之后模型几乎每次都能正确把用户要记录的内容提取成content参数。3.4 运行效果与结果实测写个简单的运行入口if __name__ __main__: reply, history agent.run(你好请问现在几点了顺便帮我记一下明天下午三点和产品团队开评审会。) print(最终回复, reply) print(\n--- 完整对话轨迹 ---) for msg in history: role msg[role] content msg.get(content) or msg.get(tool_calls) print(f[{role}] {content})跑一遍看效果模型会先调用get_current_time获取当前时间接着调用append_to_memo把会议提醒写入文件最后汇总两边的结果给用户一个完整回复。从打印出的对话轨迹里你能清楚看到每一步模型先意识到“用户问了时间我需要查询”再意识到“用户让我记录事件我需要写文件”最后才组织成自然语言回复。这种“眼见为实”的步进过程对我来说是自研框架最大的回报——你不再对着黑盒猜它是怎么工作的而是打开引擎盖看齿轮一个个转。4. 常见问题与排查技巧实录4.1 模型总是不调用工具直接自己瞎编答案这是所有 Agent 项目里最高频的问题我也在这个坑里待过很久。排查思路分三步走。第一步检查工具描述里description质量。常见问题是描述里没有出现用户口语里会用的关键词。比如你给工具起名query_orders描述写“查询订单数据”用户说的是“帮我看看我上个月买了啥”模型在意图匹配上就比较勉强。把描述改成“查询用户的历史订单/购买记录可以按时间范围筛选适合回答‘我买了什么’、‘有哪些订单’这类问题”之后匹配率显著提升。第二步看模型版本。小参数模型或者某些开源模型在 Function Calling 上天然劣势它们不是不会用工具而是经常“忘记”你的工具列表里有什么。这时候建议要么换更强的模型要么在系统提示词里额外强调一句“你可以使用工具来完成用户的请求工具列表在下方如果某个工具能解决问题请务必调用它”。第三步确认 tools 参数真的有传进去。我在调试一个用户的代码时发现他封装了 Agent 之后把 tools 参数写死成None模型当然永远不调用工具。这种低级错误其实很常见排查时优先确认“协议层是不是通的”再考虑模型智能程度。4.2 工具调用了但参数是错的或者参数格式解析失败参数解析失败通常不是模型太笨而是你的 schema 定义给了模型太多自由。举个例子parameters里如果某个字段你忘了给type模型可能会给你传一个数组而不是字符串如果你把必填字段漏写在required里模型偶尔会自作主张不传。所以写 schema 有一条铁律所有模型可能用到参数都尽量写清楚type和description所有业务上必须的参数都放进required。如果已经这么做了还是频繁解析失败那就得在代码里兜底。hermes-agent 的做法是在执行工具前做一次轻量校验遇到json.loads解析异常时不直接报错而是把原始字符串截断后返回给模型并提示“你的工具参数不是合法的 JSON请重新生成”。模型看到这个提示之后通常会自己纠正格式实测下来这一招能救回大部分偶发的脏输出。4.3 上下文窗口越用越长最终超限报错Agent 的循环机制决定了它会不断把新的工具调用结果追加到历史里如果不加控制几十轮之后必然撞上模型上下文上限。解决办法我在记忆那一节提过再补充一个实操细节不要简单粗暴地从最开始截断消息列表因为一个 Agent 任务里的关键信息往往分布在前中后。更好的策略是优先保留“系统提示词、最近两轮完整交互、所有还没被总结过的工具结果”中间部分做一次摘要压缩把摘要作为一条 system 消息插回历史。这个过程跑在后台用户无感。4.4 并发请求时工具状态互相干扰如果同一个 Agent 实例被多个请求共用而你的工具函数里有类变量或者全局变量就会出现状态污染——用户 A 写入的数据被用户 B 读到了。我的处理方案是一个请求一个实例每个用户会话在开始时创建一个全新的HermesAgent实例用完即抛成本很低完全省去状态隔离的烦恼。如果工具函数里确实需要共享资源比如同一个数据库连接池把连接池对象设计成线程安全的外部依赖注入而不要让 Agent 内部直接持有可变状态。5. 从信使到管家后续还能怎么扩展跑通核心循环之后hermes-agent 更像是一个“信使”你让它干啥它干啥没有主动性。但如果想让这个框架担当个人助理或者业务助手有两个低成本高收益的扩展方向我觉得特别值得试一试。第一个方向是给它加一层“任务路由”。现在是一个 Agent 处理所有请求但现实业务里可能需要多个专精的 Agent 协作——一个负责日程、一个负责查资料、一个负责写周报。主 Agent 先理解用户意图再把请求路由给对应的子 Agent形成“主管 专员”的团队结构。这个扩展不需要推翻现有核心循环只需要在最外层加一个意图分类器本质上还是复用run方法。第二个方向是引入长期记忆的自动沉淀机制。目前我的实现里长期记忆是“对话结束后手动抽取关键信息写入”但实际操作中经常忘了调那个写入函数。改进版的做法是在run方法返回之前自动做一次“这段对话里有没有值得长期记住的事实”判断如果模型觉得有就静默写入记忆库。这样 Agent 在跟同一个用户多聊几次之后会表现出“记得你上次说过什么”的效果体验提升非常明显。最后再说两句实在话这周我又迭代了一版 hermes-agent 的核心循环把工具异常时的错误信息做了分级——能确定是参数问题的直接给模型提示修正方向不确定的才把完整堆栈抛回去。我个人的体会是Agent 框架这类东西设计模式固然重要但真正决定好用与否的往往是这些藏在异常分支和边界条件里的小细节。项目目前已经在一个内部小工具里跑了两周稳定性和效果都还算满意后续我会继续在长期记忆和任务路由上做文章。如果你也在做类似的 Agent 项目或者正在犹豫自己造轮子还是用现成框架我的建议是如果你的业务场景里有哪怕一点定制化需求都值得从核心循环开始写一个极简版本试一两天。不要一上来就追求完整的记忆系统、复杂工具链先把“模型能正确决定调用工具”这件事跑通而后的一切都是锦上添花。