最近不少人在做《智能体框架深度实践》这一章的习题时,卡在同一个地方——看答案感觉每一步都懂,自己从头写一遍就开始到处报错。第六章这套题很有意思,表面上是让实现几个工具调用,实际上是在考一个完整的 ReAct 循环:模型怎么决定调工具、工具返回值怎么喂回给模型、循环什么时候该停。只要把这套循环吃透,所有关于智能体的面试题、项目题都不再是死记硬背,而是真正能动手的东西。
这篇内容适合三类人:正在啃智能体章节的学生、自学 Agent 应用开发的初学者、以及准备 Agent 相关面试的开发者。我会把整道题的拆解、代码实现、测试过程和踩坑记录全部合在一起,你可以直接照着复现,也能快速理解每一个“为什么”。我不建议直接调现成的智能体框架来交差,后面会解释原因,但如果你已经用框架跑通过,这篇文章能帮你把底层原理补得更扎实。
1. 先把这道题拆明白了:它到底在考什么
1.1 从一个“天气查询助手”看智能体的本质
第六章的习题通常会给你一个看起来很简单的小目标——实现一个助手,能够根据用户提问,自主决定调用哪些工具,最后给出回答。比如用户说“某城市A现在天气怎么样,今天日期是多少”,一个合格的 Agent 应该先查看日期,再查询城市天气,然后把两段信息拼成一句完整回答。这看起来平平无奇,但普通的大模型对话是做不到的,因为模型本身不知道当前的实时天气,也不会主动玩“先查日历再查天气”这套组合动作。
智能体和大模型对话的本质区别就在这里:Agent 不只是生成文本,它通过“感知环境 - 做出决策 - 执行动作 - 观察结果”的闭环,把大模型从一个聊天对象变成了一个能调用真实工具的“操作员”。在这个闭环里,大模型仍然是大脑,负责思考下一步该干什么;工具是手脚,负责实际获取数据或操作系统资源。习题要求你实现的,正是这个闭环里最关键的两件事:让模型“知道”有哪些工具可以用,以及让模型“看到”工具执行的结果。
1.2 习题考察的四个核心能力点
做这道题之前,我建议你先对照下面的表格,明确自己的代码要覆盖哪些评分点。这是某门课程第六章作业最常见的评分框架,你可以当作自查清单用。
| 评分点 | 具体要求 | 常见丢分原因 |
|---|---|---|
| 功能完整性 | 能正确调用至少两个工具,并组合结果回答复合问题 | 只实现了一个工具,或者工具调用顺序错误 |
| 异常处理 | 工具未找到、参数解析失败、模型输出格式错误时不会崩溃 | 直接抛异常退出,没有把错误转为模型可见的观察结果 |
| 循环终止 | 能区分“需要继续调工具”和“可以输出最终答案”两种状态 | 模型一直自言自语不结束,或过早结束导致答案残缺 |
| 代码可读性 | 工具定义、注册、调用逻辑分层清晰,便于扩展新工具 | 工具逻辑全部写死在 if-else 里,加一个新工具要改主循环 |
这四个评分点不是凭空来的,它们恰好对应真实 Agent 框架中四个必须解决的工程问题:工具注册、错误容错、流程控制、扩展性。你在自己动手实现时,会特别强烈地感受到每一个评分点背后都有真实踩坑的价值。
1.3 为什么我坚持让你手写一遍“微型 ReAct”而不是直接调现成框架
现在市面上的智能体框架很多,有重型的、有轻量的,一两行代码就能把 Agent 跑起来。但第六章这道习题,我的建议是:至少先做一次不依赖现成框架的手写实现。为什么要“折磨”自己?我告诉你三个实际理由。
第一,框架是黑盒。框架帮你封装了模型交互、工具调用、记忆管理,出了问题你只能看日志猜,很难真正理解是模型理解错了,还是工具描述写得不够好。手写实现后,每一行代码都是你控制的,每条报错你都能定位到具体环节,这种排查能力在外面做项目时非常值钱。
第二,面试时不会被问倒。现在 Agent 方向的技术面试基本都会追问“ReAct 循环的每一步发生了什么”“如果 Action Input 解析失败你会怎么处理”,这些问题只调用框架但没看过源码的人,很难答出实质内容。自己实现过一遍,这些问题就是送分题。
第三,扩展思路更清晰。一道习题完成后,你想加记忆、加多 Agent 协作、加流式输出,如果底层是自己写的,你会知道在哪里加、加了会影响哪些模块。如果底层是框架,你只能查框架文档有没有现成接口。学习阶段不要把“会用框架”误当成“会做 Agent”。
2. 环境准备与工具层设计
2.1 最小依赖清单与模型接入方式
先把环境说清楚。这道题只需要 Python 3.9 以上环境,核心依赖其实是“调用大模型 API 的客户端”,无论你用的是哪个服务商——这里统称某大模型服务商的接口——代码层面只需要一个支持多轮对话的 chat 函数,输入消息列表、输出回复文本即可。我会给你留一个llm_chat()占位函数,你换成自己的模型接入就行。
一个很关键的参数是temperature,建议调到 0 到 0.2 之间。Agent 场景下,你希望模型尽量稳定地输出结构化动作,而不是发挥创意编造工具名,所以采样温度必须压低。我见过有人用默认 temperature 跑 Agent,结果模型一会儿把动作格式写歪,一会儿在“思考”环节长篇大论,最后循环超时。把温度调低之后,问题立刻少一大半。
模型接口接入时,我建议打印每次请求的消息数量。这不是为了炫技,而是后面排查上下文超限时你一定会需要这行日志。代码如下:
def llm_chat(messages): """ 调用某大模型服务商的 chat 接口。 你现在只需要把下面的 TODO 换成本地可用的模型服务地址或 SDK 即可。 """ # TODO: 替换为实际的模型客户端代码 # 例如: resp = your_sdk.chat.completions.create( # model="...", messages=messages, temperature=0.1) # return resp.choices[0].message.content pass从调试角度来说,你最好固定使用一个足够聪明的模型。什么算“足够聪明”?就是能稳定理解复杂 JSON 和长工具的模型。不过与此同时,你又要尽量让 prompt 写得不依赖模型玄学,做到换一个普通模型也能跑,这才是真正的工程能力。
2.2 用“工具注册表”管理 Agent 的可用能力
你可能会想:不就是让模型调工具吗,我用 if-else 判断模型输出了哪个工具名不就行了?这样确实最简单的,但每加一个工具就要改一次主循环,而且工具参数说明完全没法让你和模型顺畅交流。更好的做法是用一张“工具注册表”:把每个工具的名字、描述、参数结构、可执行函数绑在一起,单独存成一个结构体。
这里我用到一个小技巧:工具的description不是写给程序员看的,而是写给模型看的。同一个函数,如果你把它描述成“获取指定城市的当前室外天气状况”,模型就知道该在什么时候调用它;如果你描述成“返回 weather_pool 中的一个字符串”,模型很可能因为看不懂而不调用。这个“为模型写描述”的思维方式,是从普通程序员切换到 Agent 开发者最重要的一步。
工具注册层的代码如下:
class Tool: def __init__(self, name, description, func, parameters): self.name = name self.description = description self.func = func self.parameters = parameters # JSON Schema 风格参数说明 def get_current_time(): """获取当前系统时间,精确到秒。""" from datetime import datetime return datetime.now().strftime("%Y-%m-%d %H:%M:%S") def get_city_weather(city: str) -> str: """查询指定城市的当前天气状况(习题演示用模拟数据)。""" weather_pool = { "城市A": ("晴朗", 23), "城市B": ("多云", 19), "城市C": ("小雨", 15), } if city in weather_pool: cond, temp = weather_pool[city] return f"{city}当前天气:{cond},气温{temp}℃。" return f"未找到{city}的天气数据,请确认城市名是否正确。" def days_between(date_a: str, date_b: str) -> str: """计算两个 YYYY-MM-DD 格式日期之间相差的天数。""" from datetime import date try: d1 = date.fromisoformat(date_a) d2 = date.fromisoformat(date_b) return f"{date_a}与{date_b}之间相差{abs((d2 - d1).days)}天。" except ValueError: return "日期格式错误,需要 YYYY-MM-DD 格式。" TOOL_REGISTRY = { "get_current_time": Tool( name="get_current_time", description="获取当前系统时间,包括年月日和时分秒。当用户问今天日期、现在几点时使用。", func=get_current_time, parameters={"type": "object", "properties": {}, "required": []}, ), "get_city_weather": Tool( name="get_city_weather", description="查询指定城市的当前天气状况和实时气温。参数 city 接收中文城市名。", func=get_city_weather, parameters={ "type": "object", "properties": {"city": {"type": "string", "description": "城市名,如 城市A"}}, "required": ["city"], }, ), "days_between": Tool( name="days_between", description="计算两个日期之间相差的天数。参数格式必须是 YYYY-MM-DD。", func=days_between, parameters={ "type": "object", "properties": { "date_a": {"type": "string", "description": "第一个日期,格式 YYYY-MM-DD"}, "date_b": {"type": "string", "description": "第二个日期,格式 YYYY-MM-DD"}, }, "required": ["date_a", "date_b"], }, ), }注意看,TOOL_REGISTRY用字典而不是列表,这能让主循环以最快速度根据工具名查到对应函数。另外,days_between里我很早就做好了异常处理:传入非法日期时,返回的不是异常堆栈,而是自然语言错误提示。这么做的原因会在下一节展开。
2.3 工具实现时最容易踩的三个坑
工具函数看着简单,实际上坑不少。我按踩坑频率给你列三个最容易出问题的地方。
第一,返回值的可读性。工具返回给模型的字符串,应当是一句人话,而不是裸数字或裸字典。比如days_between我返回的就是“2025-01-18与2025-12-31之间相差347天。”,而不是返回347。因为模型后续要根据这个字符串来组织最终回答,如果只有个数字,它可能会猜这是什么东西,从而导致回答语焉不详。你在定义每个工具时都要问自己:模型拿到这个字符串,能不能直接理解其中的信息?
第二,工具内部异常不能外抛。模型传参永远有可能出乎你的意料,比如把一个空字符串传给days_between,或者传一个根本不在支持列表中的城市名。处理方式是:所有工具都自己捕获异常,并返回用户/模型能看懂的错误提示。异常一旦抛到 Agent 主循环,就意味着整个任务中断;而把异常转成“观察结果”文字返回给模型,模型反而可能意识到自己传错参数,然后自动修正。
第三,注意参数名的一致性。我见过不少人把函数形参叫做d1、d2,然后把需求描述里写着“YYYY-MM-DD 格式的日期”,模型也确实按格式传了,但传的参数名和实际 JSON Schema 里的不一致,导致注册表构建时报错。工具的parameters结构一定要和函数签名严格对应,这是程序员的严谨,也是 Agent 稳定运行的基础。
3. 核心 Agent 循环的完整实现
3.1 ReAct 循环的“想、做、看”到底是什么
现在进入最核心的 ReAct 循环。ReAct 这个名字是 Reasoning + Acting 的缩写,原理说起来非常朴素——让大模型在每一轮输出里,先用THOUGHT表达自己对当前情况的判断,再用ACTION给出下一步动作。系统拿到 ACTION 后执行对应工具,把返回的OBSERVATION再粘回对话历史,交给模型继续思考。如此反复,直到模型认为自己掌握足够信息,输出FINAL ANSWER。
这个过程很像人类解决复杂问题的思路:看题目 - 想一想需要什么信息 - 查资料 - 看了资料再想 - 直到能把问题完整答出来。习题要求你实现的关键就是把这个“想 - 做 - 看”的三步闭环用代码固定下来。难点不在写循环,而在“模型不按你的要求输出”时的容错处理。比如它不输出 ACTION 而直接写OBSERVATION,或者 ACTION 的 JSON 参数格式歪歪扭扭,这些都要靠解析容错来解决。
3.2 关键代码:THOUGHT、ACTION、OBSERVATION 三步循环
我直接给出一个可以运行的ReActAgent类。这段代码是按“自己写一遍”的标准来设计的,逻辑全部显式展开。你完全可以在此基础上加功能。
import json import re class ReActAgent: def __init__(self, system_prompt, max_iterations=8): self.system_prompt = system_prompt self.max_iterations = max_iterations self.messages = [{"role": "system", "content": system_prompt}] def run(self, user_query: str) -> str: self.messages.append({"role": "user", "content": user_query}) for step in range(self.max_iterations): response = llm_chat(self.messages) content = response.strip() # 调试日志:打印模型当前这个“想法” print(f"\n[第{step + 1}轮模型输出]\n{content}") self.messages.append({"role": "assistant", "content": content}) if "FINAL ANSWER:" in content: return content.split("FINAL ANSWER:", 1)[-1].strip() action = self._parse_action(content) if action is None: # 模型没有给出合法的 ACTION,告诉它格式不对,换个说法再试 hint = ( "OBSERVATION: 上一条消息中出现非法格式或缺少 ACTION," "请严格按 ACTION: 工具名({\"参数\": \"值\"}) 输出," "或者直接输出 FINAL ANSWER: 结束对话。" ) self.messages.append({"role": "user", "content": hint}) continue tool_name, params = action tool = TOOL_REGISTRY.get(tool_name) if tool is None: observation = f"OBSERVATION: 未找到名为 {tool_name} 的工具,请检查工具名。" else: try: result = tool.func(**params) observation = f"OBSERVATION: {result}" except TypeError as e: observation = f"OBSERVATION: 工具 {tool_name} 参数错误,请检查参数名和类型。错误信息:{e}" except Exception as e: observation = f"OBSERVATION: 工具 {tool_name} 执行异常:{e}" self.messages.append({"role": "user", "content": observation}) return "达到最大迭代次数,未能生成最终答案。" def _parse_action(self, content: str): """ 从模型输出中提取 ACTION: 工具名({JSON})。 如果找不到合法动作,返回 None。 """ match = re.search(r"ACTION:\s*(\w+)\s*(\{.*\})?", content, re.S) if not match: return None tool_name = match.group(1) params_text = match.group(2) if params_text is None: return tool_name, {} try: params = json.loads(params_text) except json.JSONDecodeError as e: # 常见情况:模型把单引号当成了 JSON 引号,这里做一层轻量修复 try: params = json.loads(params_text.replace("'", '"')) return tool_name, params except json.JSONDecodeError: return tool_name, {} return tool_name, params你要特别留意的有三处逻辑。第一,_parse_action用正则去匹配ACTION:前缀,而没有在 prompt 里实现那种严格的“只允许单次输出动作”限制。因为模型偶尔会在 THOUGHT 里夹杂解释文字,正则能灵活把动作挑出来。第二,每次工具执行结果都包装成OBSERVATION: xxx再放进消息列表,这保证了系统提示词里承诺的循环格式始终成立。第三,当解析失败时我们没有直接终止,而是构造一条“OBSERVATION 提示”让模型自我修正,这其实就是在实现知识点里的“容错重试”机制。
3.3 边界情况处理:JSON 解析容错、迭代上限、工具异常
再往下走,是区分“能把任务跑通”和“生产可用”的分水岭。即使模型能力很强,你还是会遇到三类边界情况。
JSON 解析容错是最常见的。模型输出的 ACTION 参数几乎没有人能保证 100% 是合法 JSON。我见过输出{"city": "城市A"}这种合法 JSON,也见过输出{'city': '城市A'}单引号版本、输出{"city": “城市A”}中文引号版本、甚至输出截断的{"city": "城市A"少了一个右括号。我的建议是:先用标准json.loads,失败后尝试替换中文引号和单引号,再失败就放弃解析并把问题反馈给模型,让它重新生成。绝对不推荐自己去写手工拼接参数的解析逻辑,那是一条通往无底洞的路。
迭代上限的重要性和 JSON 容错差不多。模型陷入“查了这个工具,还想查那个工具”的循环时,如果没有上限,你的 API 费用和时间都会无限消耗。上界设多大?习题场景 8 轮足够了,但你要在代码里保留这个变量,真实项目里再根据任务复杂度调整。要特别提示的是,触发上限后的返回信息不要硬编码成含糊的“失败”,最好把已经收集到的中间步骤存放在日志中,方便回溯模型为什么会走到这一步。
工具异常处理的关键是把异常“藏住”并回归循环。我在代码里写了TypeError和Exception两层捕获。为什么单独留一层TypeError?因为模型传参的时候很容易少传一个参数,此时 Python 抛出的就是TypeError,而模型看到“参数错误”的提示后通常能立刻意识到要补全参数,这是真实调试中反馈效率最高的一种错误。其余的异常类型归入Exception兜底,至少保证程序不直接崩溃。
4. 实测验题场景:一次完整的任务执行
4.1 构造一条覆盖多个工具的复合问题
习题做完后,你需要一个能证明代码真的跑通的测试场景。我建议不要只测单工具调用,因为那样测不出 Agent 的决策能力。一个合格的测试输入应该满足三个条件:需要多个工具协同、依赖实时信息、有明确的最终答案。下面的例子就很典型:
用户提问:“今天是几号?帮我查一下城市A现在的天气,另外算一算距离2025年12月31日还有多少天,把这三个结果一次性告诉我。”
这个问题需要get_current_time获取当前日期,然后用get_city_weather查天气,最后用days_between计算两个日期之间的间隔。正好用一个复合问题把三个工具全部串起来,同时检验 Agent 是否先获取当前日期再去计算天数——这是这道题最微妙的决策点:先查日期,再去算日期差,顺序一旦反了,模型就会因为缺少“今天几号”的信息而计算失败。
4.2 逐步解析 Agent 的内部流转过程
为了让你直观看到 Agent 内部发生了什么,我基于某次运行截取了一段日志,时间假设为 2025-01-18。你可以在自己的输出里看到和下面类似的过程:
[第1轮模型输出] THOUGHT: 用户需要我提供当前日期、城市A天气,以及到2025年12月31日的天数。我需要先获取当前系统时间,才能计算后续日期差。 ACTION: get_current_time() [第2轮模型输出] THOUGHT: 当前日期是2025-01-18。接下来查询城市A的天气。 ACTION: get_city_weather({"city": "城市A"}) [第3轮模型输出] THOUGHT: 已获得城市A天气,晴朗23℃。现在利用当前日期2025-01-18和目标日期2025-12-31计算间隔天数。 ACTION: days_between({"date_a": "2025-01-18", "date_b": "2025-12-31"}) [第4轮模型输出] THOUGHT: 所有信息都已取得,我可以组织最终回答。 FINAL ANSWER: 今天是2025年1月18日。城市A目前晴朗,气温23℃。距离2025年12月31日还有347天。你可以看到模型使用了严格的THOUGHT / ACTION / OBSERVATION格式,这是 system prompt 设计成功的体现。从顺序上看,模型把获取当前时间放在第一步,说明它真的理解了“要计算天数,必须先知道起算日期”这个逻辑。在更复杂的测试中,如果模型没有先调用get_current_time就直接调用days_between,观察结果会提醒它参数不足,它通常会在下一轮自我修正。
4.3 对照“习题评分点”逐条自查
任务跑通后,不要急着交作业,拿前面那张评分表逐项自查一遍。我当时的自查记录可以给你参考:
- 功能完整性:三个工具全部被自动调用,且调用顺序正确,复合问题组合回答成功。满足。
- 异常处理:我把
get_city_weather的入参改成不存在的“某城市Z”,Agent 收到工具返回的错误后,在下一轮回复中主动向用户道歉并请求城市名修正,没有崩溃。满足。 - 循环终止:模型在拿到全部信息后输出
FINAL ANSWER,且没有出现漫长自说自话。满足。 - 代码可读性:工具定义在注册表里,主循环只有十几行,新加一个工具只需要在注册表加一项。满足。
自查时务必专门去破坏一个工具试试。比如故意让days_between接收非法日期,看你的 Agent 能不能体面地把错误转化为观察信息。如果这一步没有处理好,那么这道题在“异常处理”这个评分点上很可能是拿不到高分的。这也正是这道习题在真实 Agent 工程中的核心能力:系统从不保证输入必然正确,而 Agent 需要学会处理意外。
5. 扩展与加分项:从单 Agent 走向更灵活的架构
5.1 用“规划 - 执行”双层结构升级原题
你如果学有余力,或者想拿加分项,下面这个扩展方向性价比很高:把单 Agent 拆成“规划器”和“执行器”。规划器的职责是和用户直接对话,分析用户意图,把一个大问题拆成若干小步骤,然后调用执行器逐一处理。执行器则像一个“工具工人”,每轮只负责调用一个具体工具,把结果返回给规划器。
这样做的好处是,Agent 两次调用之间的职责更清晰,规划器不会被工具参数的细节淹没,执行器也不会因为反复决定下一步做什么而迷失。我维护过一个类似的个人项目,把天气查询、日程提醒、待办事项三个能力接入同一个 Agent 时,双层结构明显比单循环结构容易扩展。代价则是多了一轮模型调用的开销,所以在小习题中不必强行使用,作为一个加分设计体会思路即可。
5.2 轻量记忆与上下文滑动窗口
单次任务中,Agent 要把之前所有步骤保留在消息列表里,这也意味着每轮调用模型的 token 数都会不断增长。这个问题在长流程中尤其明显,你可能会遇到上下文长度上限。一个轻量方案是:只保留最近 N 条消息,更早的消息压缩成一条总结摘要,每次用户提问时把摘要放在最前面。这就是 Agent 里所谓的“滑动窗口记忆”。
实现上并不复杂。当你的消息列表超过阈值时,调一次模型,让它把前面所有 tool 调用和结果的要点浓缩成三四行摘要,然后清空被压缩的部分,摘要作为新的 system 一部分继续保留。很多框架都有类似机制,但你自己写下一次的收获非常大——你真正理解了为什么 Agent 会“忘事”、为什么压缩后仍然可能丢失细节,而不是只会调用框架的memory参数。
我建议把这个扩展放在习题主体完成之后再做,因为滑动窗口很可能引入新的 BUG:摘要丢失是常态,模型压缩摘要时可能会丢掉一个关键观测,进而影响最终答案。提前在简单题上验证这一步,后面做真实项目时才敢放开用。
6. 常见问题与排查技巧实录
6.1 模型就是不调用工具,满嘴跑火车
这是新手最容易遇到的头号问题。模型在对话里直接给出“城市A今天天气是晴天”,完全没调用工具,答案全靠编。排查方向有三个:第一,系统提示词里是否清楚写明了“你无法直接知道实时数据,必须调用工具获取”?很多模型没有默认的“工具意识”,你不强调它就会直接回答。第二,温度是否太高?已经说过,Agent 场景调低到 0.1 左右。第三,工具描述是否足够具体?你要检查工具描述里有没有明确说明“当用户问天气时必须调用此工具”,而不仅仅是“查询天气”。
一个有效的强制手段是:在系统提示词里加一句“在回答任何涉及实时数据的问题前,必须先在 THOUGHT 中说明你缺少数据并计划调用工具,否则直接输出 FINAL ANSWER 将被视为错误响应。”这类显式约束对大多数模型都管用。
6.2 ACTION 解析失败的三种典型表现
解析失败也是高频问题,我总结过三种最常见的表现形态,你在调试时可以对号入座。
第一种,模型输出了ACTION: get_city_weather(city=城市A)。这是函数调用的 Python 风格,不是 JSON。正则能匹配到工具名,但 params_text 后半部分根本解析不了。我的处理是:解析失败时,将“参数需要标准 JSON 格式”作为观察结果返回给模型,通常下一轮它就会修正成{"city": "城市A"}。
第二种,模型输出了ACTION: get_city_weather({"city": "城市A"}); ACTION: days_between(...)。一回合想调用多个工具,这在当前循环结构里是不支持的。产生原因往往是任务确实需要两个工具协同。我的处理是:解析正则只取第一个 ACTION,后续内容当作观察结果的一部分丢回给模型,模型看到“一次只能调用一个工具”的反馈后就会拆成多轮执行。
第三种,模型输出了ACTION: get_city_weather({"city": “城市A”}),参数里混入了中文冒号和中文引号。这最贴近真实大模型的输出习惯。我的处理是:在json.loads失败后,先把中文标点替换成英文标点再解析。这个替换逻辑极其实用,建议在解析层保留。
6.3 上下文超限、死循环和其他运行期异常
任务特别长时,你可能会看到模型把早期观察结果忘掉,或者干脆在某一轮之后就偏离主题。这是上下文窗口被占满后的典型表现。排查时先看两个指标:消息列表里每个 message 的 token 数,以及当前总 token 数。如果总 token 数接近模型限制,优先实现 5.2 节的滑动窗口压缩,而不是继续盲目加轮数。
死循环问题表现为模型反复调用同一个工具。比如它已经查到天气,下一轮还是继续查同一个城市的天气。这种情况通常是因为 observe 没有给足结构化信息,或模型想确认但没意识到自己已经有了答案。解决方案有两个层面:代码层限制最大迭代次数,这是底线;prompt 层在观察结果里补充“该工具最近已调用过一次,除非用户发出新指令,否则不要重复调用”。把这两层组合起来,死循环基本能被防住。
最后还要留一个调试技巧:永远为每轮循环打印 token 数或消息列表长度。你不需要理解每一条日志,但一旦任务异常,回看日志能非常快地定位是模型决策出了问题,还是工具执行出了问题。我当时排查一个 JSON 解析问题就是靠日志发现模型输出的 ACTION 里混入了不可见字符,单看代码完全想不到原因。
这道题做完,最直接的收获是你对“什么是智能体”有了切身的体感:它不再是一个模糊术语,而是一段固定逻辑加一个灵活控制策略的工程结构。后面再去用现成框架,你会比只学过文档的人多一层理解——你清楚框架的哪一层在做工具注册、哪一层在做循环控制、哪一层在做记忆管理,出问题时也不会对着黑盒干瞪眼。我个人建议,等这道题跑通之后,再回头把框架源码里对应你手写部分的那段代码翻出来对比一下,你会发现自己的代码已经具备了接近工业实现的雏形,而这也是第六章想让你真正掌握的东西。