☰
从单次工具调用到多工具智能体:主循环、短期记忆与踩坑实践
2026/10/17 11:32:28 网站建设 项目流程

如果让我用一句话概括第四天的 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=500

TEMPERATURE我特意调低到 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 项目里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询