如果让我用一句话概括第四天的 Agent 学习收获,那就是:智能体不是模型,而是一套工程系统。前三天我们搞清楚了提示词怎么写、大模型接口怎么调、单个工具怎么被模型“看见”,但离真正的 Agent 还差很远。今天的目标很明确:把“一个会调用工具的函数”升级成“一个能在多工具之间做选择、带着临时记忆推进任务的智能体”。
这篇是给正在学 Agent 的初学者的 Day04 记录。如果你已经能独立完成单次工具调用,但不确定下一步该学什么——多工具调度、短期记忆、Agent 主循环、以及各种让人挠头的失败模式——可以参考我今天的完整过程。我没有用分布式架构,也没有可视化编排平台,所有逻辑就是一台普通笔记本上的一段 Python 代码,但踩的坑一个都没少。
1. 开搞之前:先把 Agent 拆回工程问题
1.1 前三天的学习清单与 Day04 的定位
写代码之前,我先把前三天学的东西摊在桌面上看了一遍。这个习惯挺重要,因为 Day04 不是从零开始,它是在之前三个零件的基础上做组装。
| 阶段 | 我学到的东西 | 还缺什么 |
|---|---|---|
| Day01 | 提示词结构、角色设定、少样本示例 | 不知道怎么接入数据,模型只会“说”不会“做” |
| Day02 | 模型接口、消息格式、system/user/assistant 的关系 | 只有纯文本对话,任务没法闭环 |
| Day03 | 让模型输出固定 JSON,命令一个工具 | 工具一多就手忙脚乱,没有统一调度 |
| Day04 | 多工具注册、短期记忆、Agent 主循环 | 目标是把前面拼成一个最小可用的智能体 |
这个表格也帮我确认了一件事:Day04 真正要解决的不是“让模型更聪明”,而是“让模型在一个明确的循环里工作”。模型负责决策,代码负责执行,记忆负责把上下文带下去,三者缺一不可。
1.2 架构上最重要的三件事
我先把 Agent 拆成两个毫不浪漫的词:调度和上下文。调度解决的是“模型下一步该调用哪个工具”,上下文解决的是“模型根据什么历史来决策”。围绕这两个词,今天我只盯着三件事。
第一,模型只是决策器,不是执行器。模型没有手,它产生的是“意图”,真正执行搜索、计算、读写文件的是背后的函数。这个职责分离非常重要,因为你可以随时替换模型、替换工具,而不用把两者揉在一起。
第二,工具必须标准化。模型不可能读懂你的源代码,它只能读你提供的工具说明。如果每个工具的格式都不一样,模型就像面对一堆没有标签的按钮,按错是必然的。所以我做了一个极简的工具注册表,让每个工具都以统一格式暴露给模型。
第三,记忆必须可控。记忆不是聊天记录的无脑堆叠,而是一块随时可以裁剪的工作台。模型每次决策都要把上下文重新看一遍,如果记忆膨胀到几万 token,速度会下降,成本会上升,更麻烦的是模型会被无关信息带偏。Day04 我没碰长期向量库,只做了短期记忆,但对一个单 Agent 任务来说已经够用。
1.3 我给自己设的边界
学习最容易翻车的地方是想一口气吃成胖子。今天的代码我刻意做了三个限制:只做单 Agent,不搞多 Agent 协作;只做短期记忆,不装向量数据库;优先使用大模型原生的 function calling,而不是让模型输出一堆 JSON 再自己解析。这三个边界帮我避开了大量无关干扰,让我能把主循环本身做扎实。
2. 环境准备:把骨架搭好再谈智能
2.1 项目目录:小但完整
我今天没有用任何重型框架,目录结构非常简单:
agent-day04/ ├── .env ├── main.py ├── memory.py ├── registry.py └── tools/ ├── __init__.py ├── web.py └── math.py解释一下每个文件的作用:registry.py是工具的统一出入口,所有工具都要在这里注册;memory.py管理对话历史,负责记录和裁剪;tools/下面每个文件放一个工具,互相不依赖;main.py跑 Agent 主循环。这个拆分不是为了好看,而是为了排查问题时知道该看哪个文件。
尤其提醒一点:不要把所有代码都塞进一个文件里。Agent 的调试非常依赖“复现过程”,文件拆得清楚,你看到报错栈的时候才能一眼定位。
2.2 配置与大模型客户端
我把模型相关的配置放进了.env,而不是写在代码里:
LLM_BASE_URL=https://your-llm-host.example.com/v1 LLM_API_KEY=sk-your-key LLM_MODEL=your-llm TEMPERATURE=0.2 MAX_CONTEXT_TOKENS=8000 MAX_TOOL_RESULT_CHARS=500TEMPERATURE我特意调低到 0.2。Agent 场景下我们需要模型稳定地输出工具调用,而不是发挥创造力。温度越高,模型越想“自由发挥”,写出的参数越不稳定,所以越低越好。MAX_TOOL_RESULT_CHARS是今天踩坑之后加上去的,后面详细说。
模型客户端我也做了一层薄封装,核心就是一个chat方法:
import os from dataclasses import dataclass @dataclass class LLMConfig: base_url: str api_key: str model: str temperature: float = 0.2 max_tokens: int = 2000 class LLMClient: def __init__(self, config: LLMConfig): self.config = config def chat(self, messages: list, tools: list | None = None): # 实际请求用的是大模型平台提供的 HTTP SDK # 关键点:tools 是独立的参数,不要拼进 messages pass为什么要再包一层?因为我不想在业务代码里到处塞 SDK 调用。将来换模型、改接口,只动这一个文件就行。同时也方便我写 mock,在不用真模型的情况下先把主循环逻辑调通。
2.3 工具函数的铁律:返回必须能变成字符串
今天定义工具时我给自己定了一条铁律:所有工具函数的返回值必须能序列化成字符串,并且在写入记忆之前强制转成字符串。原因是模型读到的上下文本质上是文本,工具如果返回 Python 对象、字典、列表,下一步就很容易出现格式错误。最简单的方式就是在工具函数末尾统一return str(result),或者由注册表在调用后做一次类型转换。
3. 工具不止一个:先做“工具注册表”
3.1 为什么不能写一堆 if-else
假设只有两个工具,你很容易写出这样的调度:
if tool_name == "web_search": return web_search(query) elif tool_name == "calculator": return calculator(expression)三个工具还能忍,等到五个以上,这段代码就会变成垃圾场。每新增一个工具都要去改调度函数,模型拿到的还是一堆散落的函数名,没有统一说明书。更麻烦的是,工具之间的描述、参数 schema 会越写越乱。
所以我换成了注册表模式。注册表的作用就像一个岗位说明书册子:每个工具注册进去之后,模型看到的不是函数本身,而是结构化的“技能卡片”。卡片上写清楚工具叫什么、什么时候用、参数长什么样。这样模型做选择时,看到的永远是干净信息。
3.2 一个极简工具注册器
我的实现很朴素,没有花哨框架:
from typing import Callable, Dict class ToolRegistry: def __init__(self): self._tools: Dict[str, dict] = {} def register(self, name: str, description: str, parameters: dict): def decorator(func: Callable): self._tools[name] = { "name": name, "description": description, "parameters": parameters, "function": func, } return func return decorator def list_tools(self) -> list[dict]: # 模型只需要看到工具的说明,不需要看到函数本身 return [ {k: v for k, v in tool.items() if k != "function"} for tool in self._tools.values() ] def call(self, name: str, **kwargs): tool = self._tools[name] return tool["function"](**kwargs)注册两个工具试试:
registry = ToolRegistry() @registry.register( name="web_search", description="搜索公开网页信息,适合查新闻、事实、资料。当用户需要实时信息时使用。", parameters={ "type": "object", "properties": { "query": { "type": "string", "description": "搜索关键词,尽量用完整问题短语" } }, "required": ["query"] } ) def web_search(query: str) -> str: # 这里我暂时返回模拟结果,先把链路跑通 return f"【模拟搜索结果】关于“{query}”的摘要信息。" @registry.register( name="calculator", description="计算数学表达式,支持四则运算。适合计算、换算、数值处理。", parameters={ "type": "object", "properties": { "expression": { "type": "string", "description": "四则运算表达式,例如 (1080+20)*3/100" } }, "required": ["expression"] } ) def calculator(expression: str) -> str: expression = expression.replace("×", "*").replace("÷", "/") allowed = set("0123456789+-*/(). ") if not all(c in allowed for c in expression): return "错误:表达式包含非法字符" try: result = eval(expression, {"__builtins__": {}}, {}) return str(result) except Exception as exc: return f"计算失败:{exc}"这里必须说明一下:eval在生产环境有风险,只适合本地学习验证。真实项目里要么用沙箱,要么把表达式解析成 AST 再只放行四则运算节点。
3.3 工具描述怎么写,直接决定调用成功率
工具描述看着简单,其实是今天最容易忽略的细节。我给同一个工具写两个版本对比一下:
坏描述:search(query)
好描述:web_search(query):搜索公开网页信息,适合查新闻、事实、资料。当用户需要实时信息时使用。
区别在于,模型不会读你的源码,它只读描述和参数名。如果你把函数命名为search,模型可能在任何不确定的时候都去调用它;如果你在描述里写清楚“什么时候用、什么时候不要用”,模型的判断就会准很多。
再比如参数expression,我在 schema 里加了一个例子(1080+20)*3/100。这个例子非常重要,因为模型经常会把“今天的温度 30 度加上 5 再除以 2”直接翻译成30度+5/2,加一个具体例子能显著降低格式错误。
4. 记忆与上下文:让 Agent 不是“金鱼脑”
4.1 短期记忆就是保留最近的对话与工具结果
所谓短期记忆,就是在当前任务运行期间维护一个消息数组,每一步都把它喂给模型。模型本身没有记忆,它每次看到的都是你拼好的上下文。因此这个数组就是 Agent 的工作台。
我用一个MessageHistory类来管理:
from dataclasses import dataclass @dataclass class Message: role: str content: str class MessageHistory: def __init__(self, budget_tokens: int = 6000): self.messages = [] self.budget_tokens = budget_tokens def add(self, role: str, content: str): self.messages.append(Message(role, content)) self.trim(self.budget_tokens) def to_api_messages(self) -> list[dict]: return [{"role": m.role, "content": m.content} for m in self.messages]在把工具结果写入记忆时,我建议给它一个独立角色,比如tool。这样模型能明确区分“用户说的话”和“机器返回的观测结果”。如果你用的模型平台不支持独立 tool 角色,再退而求其次拼进一条 assistant 消息里,但效果会差一些。
4.2 Token 预算与裁剪策略
上下文不是无限的,我给自己设定了一个总预算。每次调用模型前,把整个消息数组序列化,估算 token 数,如果超出预算就执行裁剪。
一个粗糙但有效的估算方法:
def estimate_tokens(text: str) -> int: # 中文场景下,一个字符大约相当于 1~2 token,这里按 1.5 估算 return int(len(text) * 1.5)裁剪策略我按优先级排序:系统提示词永远保留;最近一轮的 user 和 assistant 消息必须保留;工具返回的旧结果优先丢掉;再不够就把中间的老消息整体压缩成一句话摘要。
SYSTEM_PROMPT_TOKENS = 500 def trim(self, budget_tokens: int): start_idx = 0 if self.messages and self.messages[0].role == "system": start_idx = 1 total = SYSTEM_PROMPT_TOKENS + estimate_tokens( "".join(m.content for m in self.messages) ) while total > budget_tokens and len(self.messages) > start_idx + 3: removed = self.messages.pop(start_idx) total -= estimate_tokens(removed.content)注意这里的条件len(self.messages) > start_idx + 3,意思是至少保留最近三条消息,不能把对话清空。这个策略虽然简单,但在 Day04 的模拟任务里已经够用。真实项目里还需要结合消息时间戳、重要程度来做更细的优先级。
4.3 工具结果返回后先截断再入脑
今天踩得最深的坑就是工具返回太长。模型可能一次拿到几千字搜索结果,直接塞进上下文,结果后面的决策质量直线下降,甚至直接把上下文撑爆。
我的解决办法是在工具结果进入记忆之前就截断到MAX_TOOL_RESULT_CHARS,默认 500 个字符。截断不是简单从第 0 个字符切到第 500 个,而是保留开头 200 个字符和结尾 100 个字符,中间用省略号代替。这样既保留了核心开头,也留住了可能出现在结尾的结论。截断后我会在结果末尾加一句“摘要可能不完整,如需要细节请再次搜索”,提醒模型可以继续调用工具。
5. Agent 主循环:规划、调用、总结
5.1 一个最简但完整的主循环
组装完整体的核心就是主循环。每次循环里,我把历史消息和工具列表一起发给模型。模型返回结果有两种可能:要么给出最终回答,要么要求调用一个或多个工具。如果是工具调用,我执行工具、把结果写回记忆、然后继续下一轮;如果是最终回答,我就退出循环。
MAX_ITERATIONS = 5 def run_agent(user_query: str) -> str: history.add("user", user_query) for step in range(MAX_ITERATIONS): response = llm.chat( messages=history.to_api_messages(), tools=registry.list_tools() ) if response.finish_reason == "tool_calls": for tool_call in response.tool_calls: history.add("assistant", f"[调用工具 {tool_call.name}]") result = registry.call(tool_call.name, **tool_call.arguments) history.add("tool", result) continue final_answer = response.content history.add("assistant", final_answer) return final_answer return "已达到最大迭代次数,任务未完成。"这段代码省略了真实 SDK 里 tool_call 的 id 关联逻辑,但核心结构就是这样。MAX_ITERATIONS是安全阀,防止 Agent 陷入死循环。Day04 我设为 5,已经能覆盖绝大多数简单任务。
5.2 一次完整执行长什么样
用一个例子看整个过程。用户问:“查一下某市今天最高气温,然后换算成华氏度。”
第一步,模型看到工具列表里有web_search和calculator,返回一个工具调用:web_search(query="某市今天最高气温")。主循环执行工具,把结果某市今天最高气温 32°C写入记忆。
第二步,模型看到搜索结果 32°C,知道下一步需要换算,于是返回第二个工具调用:calculator(expression="32*9/5+32")。主循环执行工具,把结果89.6写入记忆。
第三步,模型看到历史里已经有了搜索结果和计算结果,于是返回最终回答:“某市今天最高气温 32°C,约等于 89.6°F。”循环结束。
这个过程看着简单,但它说明了一个核心机制:工具结果不是直接返回给用户,而是先回到模型的上下文里,由模型决定下一步怎么做。这就是 Agent 和普通脚本的分水岭。
5.3 日志与可观测性:没有日志就别谈调优
今天最大的一笔时间投入不是写代码,而是加日志。我在主循环的每个动作点都打了一行结构化日志,记录事件类型、内容、耗时:
user : 查一下某市今天最高气温,然后换算成华氏度 assistant : tool_call web_search(query=某市今天最高气温) tool : 某市今天最高气温 32°C assistant : tool_call calculator(expression=32*9/5+32) tool : 89.6 assistant : 某市今天最高气温 32°C,约等于 89.6°F这段日志就是 Agent 的“黑匣子”。后面遇到打转、幻觉、参数错误,靠的都是它。我建议在开发阶段给每个工具返回加上耗时前缀,比如[tool: web_search, 213ms] 结果...,这样你能一眼判断是工具慢还是模型慢。
6. 踩坑实录:Day04 我遇到的四个问题
6.1 工具返回过长,直接把上下文撑爆
第一次完整跑通主循环后,我拿一个真实搜索接口试了试,结果模型开始胡言乱语。打开日志一看,工具返回了三千多字,全塞进了上下文。模型面对超长正文,完全忘了自己要干什么。
解决办法就是前面写的截断:工具结果超过 500 个字符就裁剪,并且保留“摘要可能不完整”的提示。另外,即使截断,也要包含在 token 预算里,不要让历史无限增长。这个坑几乎是每个 Agent 新手都会踩的,越早处理越省心。
6.2 Agent 在同一工具上反复打转
主循环跑了几轮后,我发现模型会反复调用同一个工具,参数几乎一模一样。比如已经查过“某市天气”,下一步它又查一遍,像个死循环。MAX_ITERATIONS虽然能终止,但结果不理想。
我的临时方案是加了一层重复检测:如果连续两步调用的工具名相同、参数也相同,就中断循环,并返回当前已有信息。你可以在主循环里加一个状态变量来实现:
if (tool_call.name == last_tool_name and tool_call.arguments == last_tool_args): return "检测到重复工具调用,已停止。当前信息可能足够,请用户补充或确认。"这个方案不是最优解,但能防止烧钱。更优雅的做法是让模型在工具返回里看到更多有用信息,而不是每次返回一样的摘要。
6.3 模型把工具参数理解错了
模型经常把中文输入里的乘号、除号直接传给计算器。比如用户说“32 乘以 9 除以 5 加 32”,模型可能生成32×9÷5+32,而calculator函数只认 ASCII 的*和/。
我在工具函数入口统一做了字符清洗:expression.replace("×", "*").replace("÷", "/")。同时,在参数 schema 描述里明确写了“只接受 ASCII 的 * / + - 括号”。这两个动作配合起来,参数错误率明显下降。这类问题不是模型笨,而是描述和工具之间缺少一层容错。
6.4 模型“假装调用工具”却没用工具调用
还有一种让人抓狂的情况:模型嘴上说“我来查一下”,但返回里根本没有 tool_calls,只有一段解释文本。如果模型平台本身支持 function calling,出现这种问题大概率是提示词里没有把任务边界说清楚。
我的处理方式是:在主循环里只认finish_reason == "tool_calls",否则一律把模型输出当最终回答。如果你用的是不原生支持 function calling 的文本模型,那就不要硬套这个逻辑,先换一个支持能力足够的模型,或者改成把工具说明写进提示词、让模型输出固定 JSON 的老方案。Day04 我最终选择了优先原生 function calling,因为省掉一层 JSON 解析容错,链路更稳。
6.5 快速排查速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 上下文溢出 | 工具返回太长或历史累积太多 | 截断工具结果,设置 token 预算 |
| Agent 打转 | 缺少终止条件或信息不足 | 设置最大迭代次数,加重复检测 |
| 参数格式错误 | 工具描述不清晰或模型容错不足 | schema 加例子,工具入口做清洗 |
| 模型返回纯文本但不调用工具 | 模型原生能力不支持或边界没说清 | 换支持 function calling 的模型 |
| 结果质量差 | 上下文被早期长文本污染 | 裁剪早期消息,保留最近上下文 |
这张表我贴在显示器旁边,后面几天写多 Agent 的时候继续用。排查 Agent 问题,本质上就是对着日志把上述五个环节挨个过一遍。
7. Day04 之后的一点个人体会
如果只让我分享一条经验,那就是:AI 圈喜欢把 Agent 讲得很玄,但真正落地时,九成工作是在做日志、裁剪上下文、处理异常参数。今天这三个小时,我花了一大半时间在看日志。很枯燥,但看日志真的能发现模型在想什么,它比任何宣传文案都诚实。
最后再分享一个小技巧:开发阶段给所有工具返回加一个固定的前缀,比如[工具名: 耗时]。我一开始没加,后来排查时对着日志完全分不清哪一步是搜索、哪一步是计算。加上之后,每一步一眼就能看明白。这个习惯我会一直保留到后续的复杂 Agent 项目里。