最近两周我都在折腾同一件事:从零开始构建一个Agent(智能体)。倒不是因为市面上没有现成的框架,而是我想把“构建”这个过程完整地走一遍,自己定义工具、自己写主循环、自己接记忆,踩一遍坑才知道智能体是怎么“想”和“做”的。这篇文章就是这次体验的完整记录,我会尽量把这套最小可用的Agent流程讲清楚,包括为什么这样设计、每一步怎么做、遇到问题怎么查。适合那些已经会调用大模型API、但还没完整构建过Agent的朋友。
1. 项目整体设计与思路拆解
1.1 智能体到底和聊天机器人差在哪
很多人以为Agent就是“能聊天的机器人”,其实差别很大。聊天机器人解决的是“问答”,你问一句它答一句,它的世界只存在于对话窗口里。Agent解决的是“任务”,它要能感知用户意图、拆解步骤、调用外部工具、拿到结果后再决定下一步,最后输出一个对用户有用的结论。我用一个生活化的类比:聊天机器人像一个只会给建议的旅游攻略,Agent则像一个旅行管家,它能根据你的预算和偏好,帮你订机票、排行程、订餐厅,中间出问题还会自己换方案。
这个区别直接决定了架构。聊天机器人只需要“模型+上下文窗口”,而Agent至少需要四个模块:大脑(LLM)、手脚(工具集)、记忆(上下文和偏好)、控制器(主循环)。所以从零开始构建Agent,关键不是把接口调通,而是把四个模块串成一个闭环。回头看,我前期的很多折腾都浪费在模块之间的“接口定义”上,比如工具返回格式不统一、时间上下文没传递、历史消息无限膨胀,这些问题只有在动手写第一个版本时才会暴露出来。
1.2 我选的技术路线:LLM + 工具调用 + 轻量记忆
我这次没有用现成的Agent编排框架,而是选择“大模型API + 函数调用 + 工具集 + 轻量记忆”这套最小闭环。原因很直接:我想彻底搞清楚每一步在发生什么。框架确实能节省时间,但它隐藏了太多细节,出了问题往往无从下手。自己从零写一遍之后,再去用框架会顺手得多,因为你至少知道框架背后帮你做了哪几件事。
具体路线是:用一个大模型API作为核心决策引擎,通过它自带的函数调用能力让模型选择工具;工具层包含天气查询、提醒设置、知识库问答三个函数;记忆层分为短期对话历史和长期偏好存储;最后用一个Python写的主循环把它们粘起来。我没有引入消息队列、分布式服务这些重组件,整个Agent可以在一台普通笔记本上跑起来。这也是我想传达的一个点:Agent并不是一个必须依赖复杂基础设施的东西,它本质上就是一个“循环 + 工具注册表 + 上下文管理”的组合。
1.3 功能边界:先把三类场景跑通
我给这个模拟项目X定的初始范围是三个典型场景:天气查询、提醒设置、知识库问答。为什么选这三个?因为它们的难度恰好是递进的。天气查询只需要模型识别出“城市”这一个参数,适合验证基础的工具调用链路。提醒设置需要把口语化时间转换成时间戳,能逼我们考虑“模型不知道当前时间”的问题。知识库问答则涉及向量检索和上下文拼接,能把记忆模块带出来。
在正式开始之前,我还做了一次“需求收敛”。我原本想加上邮件自动发送、网页内容总结、日历同步,后来全部砍掉了。因为智能体的复杂度是指数增长的,每多一个工具,就意味着多一种参数解析错误、多一类工具冲突的可能性。先把三个场景跑通,等主循环稳定了,再往工具注册表里加新函数,才是低成本的做法。
2. 环境准备与模型层配置
2.1 开发环境与密钥管理
建议使用Python 3.11以上版本,因为新版类型注解和第三方库兼容性更好。我先建了一个干净的虚拟环境,避免污染系统Python。依赖方面只装了HTTP客户端库、环境变量读取库,以及一个本地轻量级向量库的客户端,尽量少引框架,减少排障面。
密钥管理是我这次特别注意的一点。大模型API密钥、天气服务接口密钥都属于敏感信息,不能写死在代码里。我用一个环境变量文件存储,并在代码里通过读取环境变量的方式获取,同时把这个环境变量文件加入忽略列表。如果你和我一样要把代码提交到仓库,这一步一定要做,否则密钥泄露是迟早的事。另外,不同密钥最好分开命名,比如模型密钥用MODEL_API_KEY,天气服务密钥用WEATHER_API_KEY,这样排查问题时会省很多力气。
2.2 大模型接口接入三个关键参数
接入大模型API本身不复杂,构造一个HTTP请求把提示词和工具定义发过去就行。复杂的是参数调优。我实测下来,有三个参数对Agent行为影响最大。
第一个是temperature。做工具调用时我把它设得很低,甚至设为0。因为工具调用是确定性的任务,我不希望模型发挥“创造力”去编造一个不存在的工具名或参数。第二个是max_tokens。回答文本和工具调用都会占用输出token,太短会导致模型在需要调用工具时话还没说完就被截断。我给普通回答设800,对需要携带大段知识库内容的回答设1200。第三个是系统提示词。我会在系统提示里明确告诉模型:“你有工具可用,当用户意图与某个工具匹配时,优先调用工具;调用成功后,再把工具结果转成自然语言回复。”这能明显减少模型“自作主张回答而不调用工具”的情况。
还有一个容易被忽略的点:要在系统提示词里注入当前时间。模型本身不知道“现在”是什么时候,如果不告诉它,它就无法正确处理“明天早上”“十分钟后”这类时间表达。我一开始没这么做,导致提醒工具的时间参数经常出错,后面会详细说这个坑。
2.3 工具Schema:一句话怎么写决定Agent智商
函数调用机制和普通聊天最大的不同在于,你要给模型一份“工具说明书”。这份说明书的格式直接决定模型能不能正确调用工具。我用的工具Schema大致长这样:
{ "name": "get_weather", "description": "查询指定城市当天的天气情况。城市名请使用中文全称,例如:北京、上海、广州。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如上海" } }, "required": ["city"] } }这里有个很容易被忽视的点:description越具体越好。比如天气工具,如果只写“查询天气”,模型很可能把用户说的“上海今天热吗”理解成一个布尔查询,传一个奇怪参数;但写清楚“城市名请使用中文全称,例如:北京、上海”,模型就会倾向传入“上海”。本质上,模型是根据描述里的模式去匹配用户输入的,描述越像真实使用场景,匹配率越高。
同理,提醒工具需要说明时间格式。我会在参数描述里写:“时间请转换为ISO 8601格式”。这比让模型自由发挥强得多。另外,工具参数最好控制在五个以内,超过五个模型很容易漏填。如果确实有复杂参数,拆成多个简单工具比硬塞一个复杂工具有效得多。
3. 从零实现Agent核心主循环
3.1 主循环:让模型自己决定下一步
Agent的核心是一个循环。我把这个循环叫做“决策-执行-反馈”循环。每一轮,模型先看用户消息和已有上下文,决定是直接回答还是调用某个工具;如果调用工具,我的代码就执行对应函数,把结果作为一条新消息塞回对话里;模型看到工具结果后,再决定是继续调用下一个工具还是输出最终回答。
用伪代码表达是这样:
def run_agent(user_input, history): messages = build_messages(user_input, history) for _ in range(MAX_ITER): # 最多执行5轮,防止死循环 response = call_llm(messages, tools=TOOLS) if response.has_tool_calls(): 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 return "处理超时,请简化你的需求"这里有两个关键点。第一个是“把工具结果作为消息塞回对话”,这一步不能省。模型只有看到工具的真实返回,才能生成可信的最终答案。第二个是轮次上限。我曾经见过Agent因为一个边缘情况,在工具之间来回调用十几次:查了天气又查提醒,查完提醒又去查知识库,最后把上下文塞满了。所以循环一定要有上限,我设的是5轮,超了就返回兜底文案。
3.2 工具函数落地:天气、提醒、知识库
实际写工具函数时,踩坑最多的是参数处理。天气工具的输入是城市名,但用户口语里可能出现“上海市”“魔都”等变体。我做了两层处理:第一层让模型尽量输出标准城市名,第二层在函数内部维护一个小型同义词映射,把常见别名映射到标准名。这样即使模型偶尔输出非标准格式,也能兜底。
提醒工具的核心难题是时间解析。为了解决“模型不知道当前时间”这个问题,我在系统提示里注入了当前时间,同时在工具描述里明确要求输出ISO时间。我还额外加了一个get_current_time工具,让模型在时间不明确时可以主动查询。交互流程变成:用户说“明天早上九点提醒我开会”,模型先决定调用get_current_time,拿到当前时间后推算“明天早上九点”对应的时间戳,再调用add_reminder。虽然多了一次调用,但准确率提升非常明显。
知识库工具我用了最轻量的方案:把几篇文档按段落切分,每段交给嵌入模型转成向量,存在本地向量库;查询时把用户问题转成向量,取相似度最高的Top3段落拼进上下文。由于知识库很小,检索耗时可以忽略。但要注意,嵌入模型和大模型尽量用同一套API体系,否则向量维度或服务地址对不上会白折腾。
3.3 记忆模块:从失忆到持续对话
第一版Agent没有记忆,每次调用都是孤立的。用户问完天气再问“那明天呢”,它完全不知道“那”指什么。这个问题必须靠记忆模块解决。
我做了两层记忆。短期记忆就是会话内的历史消息列表,每次请求都带上,但几千字总会超限。我采用了一个简单策略:当历史消息总token超过阈值时,把最早的一部分消息丢弃,只保留最近六轮完整对话,同时如果会话里有明确的待办上下文,把它压缩成一句摘要放在系统提示里。长期记忆放在一个本地轻量级数据库里,记录用户常驻城市、常用时间偏好、以及提醒任务本身。这样用户下次说“外出去办点事”,Agent可以根据存储在偏好里的常用城市来猜测意图,而不是完全从零开始。
持久化很关键。我每轮对话结束后会写一次本地文件,重启程序后能把之前的对话和待办事项恢复出来。刚开始我没做这步,程序一退出提醒设置就全部丢失,这显然不能叫“智能体”。做完这一步之后,整个Agent才算有了“连续工作经验”。
4. 测试评估与高频问题排查
4.1 测试用例怎么设计才有效
Agent是概率系统,不能靠一次运行通过就算完成。我建了一个大约30条的测试集,按难度分成三类。
第一类是明确指令:比如“上海今天有雨吗”“提醒我周五下午三点交报表”。这类用例用来验证最基础的意图识别和参数提取,预期是工具被正确调用且参数准确。第二类是模糊表达:比如“北京冷吗”“帮我安排一下下午的会议提醒”。这类测试能压出模型对上下文和常识的依赖,我往往要看工具参数是否正确提取,而不仅仅是看结果对不对。第三类是连续多轮对话:先问天气,再问“那上海呢”,再问“顺便把上海下周一的天气也查了”。这能验证记忆是否真正生效。
跑测试时我会做一个简单的统计脚本,记录每一轮是否成功、失败时模型返回了什么、工具调用了哪些。最后算出“意图识别准确率”和“任务完整执行率”两个指标。第一版大概只有62%,反复调整提示词和工具描述之后能到85%以上。不要小看这个数字,达到85%只是一个“可用”的门槛,距离“好用”还差得远。
4.2 高频问题速查表
| 现象 | 常见原因 | 排查思路 |
|---|---|---|
| 模型直接回答,不调用工具 | tools参数没传,或工具描述与用户意图匹配度低 | 检查请求体里是否带tools;重写描述,增加示例 |
| 工具参数缺失或格式错 | 模型缺少当前时间、地点等上下文 | 在系统提示注入现状;增加get_current_time工具 |
| 工具结果没有被转述 | 工具结果以原始JSON塞进对话,缺少“转成自然语言”约束 | 在系统提示里明确要求必须用自然语言概括工具结果 |
| 同一个工具被反复调用 | 工具返回错误信息不够醒目,模型误以为需要重试 | 用错误前缀标记;设置轮次上限 |
| 上下文超长导致报错 | 历史消息无节制累积 | 做裁剪和摘要,限制消息条数 |
| 知识库回答出现虚构 | 召回太少,或置信度阈值太高 | 调整召回数量,增加“未命中时如实说明”的指令 |
这六个问题基本覆盖了我这次从零构建过程中遇到的80%问题。遇到问题时不要急着改代码,先把日志打开,把“模型收到什么、工具返回什么、最终输出了什么”全部打出来,大部分问题一眼就能看出来。我这次就吃了不少“盲改”的亏,后来老老实实把每一轮消息都打出来,问题反而清晰了。
4.3 三个印象最深的坑与解决实录
第一个坑是时间。第一版让用户说“十分钟后提醒我”,模型直接调用add_reminder,参数里传的是文字“十分钟后”,后端自然解析失败。我查日志发现模型根本没有当前时间的概念,它只是把用户表达照搬了。解决办法是系统提示里注入当前时间,同时加了一个get_current_time工具。改完之后,“十分钟后”会被模型先转换成具体时间戳再传给工具。
第二个坑是工具描述太抽象。我给知识库查询工具写的描述是“查询知识库内容”,结果模型经常把“帮我查一下上次会议记录”解析成一次普通对话,而不是工具调用。改成“查询内部知识库中关于项目、会议、制度的文档内容;支持检索多个关键词;无法确定时不要调用”之后,准确率提升非常明显。可见工具描述不只是给人看的,更是给模型看的检索线索。
第三个坑是死循环。有一次我测试“帮我看看上海明天天气,如果下雨就提醒我带伞”,Agent先查了天气,发现下雨,然后调用add_reminder,但提醒工具返回了一个错误,因为时间格式传成了中文。模型看到错误后又尝试调用add_reminder,又失败,来回好几次。最后我发现工具返回的错误内容很简单,没有明确提示模型“参数错误,不要再调”。我改成:工具返回统一为结构化JSON,其中错误时状态标记为失败,并在内容里写明“请勿重试,向用户说明失败原因”。这个改动彻底解决了循环。
5. 复盘:这次构建Agent的收获与建议
5.1 我看到的关键能力:约束与可观测
从零构建一遍之后,我最大的体会是:Agent的质量不是靠模型智能堆出来的,而是靠约束和可观测性堆出来的。约束包括工具描述、输出格式、轮次上限、系统提示;可观测性包括日志、每轮消息记录、工具调用轨迹。这两样缺一不可。没有约束,再聪明的模型也会乱来;没有可观测性,出了问题只能瞎猜。
可观测性我做得比较后置,现在后悔了。建议你在写主循环的第一天就加日志:记录每次LLM请求的token、模型选择要调用的工具、参数是什么、工具执行了多久、返回结果是什么。后面调优会节省大量时间。我最初嫌日志麻烦,只打印最终回答,结果每次调试都要推理“中间发生了什么”,效率很低。
5.2 后续扩展方向:多智能体与记忆压缩
这个最小版本跑通之后,可扩展的方向其实很清晰。第一个方向是多智能体协作,比如拆一个规划Agent负责拆解任务,一个执行Agent负责调用工具,一个审核Agent负责检查结果,三个Agent通过消息队列协作。第二个方向是记忆压缩,目前我只是简单丢弃旧消息,后续可以引入摘要生成,把每轮对话压缩成结构化要点存进长期记忆。第三个方向是工具自动发现,让Agent在启动时扫描函数列表并自动生成Schema,而不是手工维护。
从零构建Agent的体验,其实很像第一次组装电脑:按照教程把零件插上,点亮那一刻很有成就感;但真正让你成长的,是点亮之后发现点不亮,于是开始查内存条、查供电、查跳线。智能体也是如此,跑通主循环只是起点,后面的每一次报错、每一轮调试,才是你和它互相理解的过程。