如果你看到这篇,说明我们已经一起从大模型原理、Prompt工程、Function Calling,一路聊到了知识库。这个系列走到第五篇,我不想再铺概念了,而是想用一个最朴素的问题切入:怎么让大模型真正"把活干完"。
很多朋友试过这样的操作:给大模型一段非常完整的指令,让它"查资料、做对比、写报告、存文件",结果模型洋洋洒洒回了一篇计划书,文件当然也没存。这不是模型不够聪明,而是你的调用方式还停留在"问答式"。问答式交互的本质是一次性的:用户问、模型答,一切信息都在这一轮里终结。但真实世界的任务,很少能靠一次问答解决。它需要模型先理解目标,再拆解步骤,调工具,看结果,失败了重来,最后给出交付物——这是一个循环,不是一个回合。
这篇就是来聊聊怎么搭起这个循环。我会从最基础的ReAct模式讲起,再到任务规划、状态管理,最后用一个自动调研Agent的实战把整条链路串起来。适合已经能熟练调用大模型接口、但还没想清楚Agent整体怎么设计的开发者。如果你只是好奇Agent是什么,也能看懂,我会尽量把每个环节"为什么这么做"讲透。
1. 从对话框到Agent:多一步"循环"改变了什么
1.1 这五篇系列文章的整体脉络
在这个系列的第五篇,我想先帮大家梳理一下我们已经走过的路。第一篇是基础,搞清楚了LLM的token机制、上下文窗口、temperature这些参数到底在做什么;第二篇专注Prompt工程,讲了怎么用System、Few-shot和思维链提示让模型输出更稳定;第三篇落到Function Calling,让模型可以调用外部工具,这是Agent的"手";第四篇是知识库与向量检索,解决的是模型"记不住私有知识"的问题,相当于给了Agent"外接大脑"。
到了这篇,前面这些能力终于要合到一起了。如果把大模型比作一个刚入职的实习生:前四篇解决的是"他懂常识、听得懂指令、会用手、能查内部资料",而这一篇要解决的是最后一个问题——怎么给他一套工作方法,让他能独立从头到尾做完一件复杂的事。这套工作方法,在AI Agent领域对应的核心技术就是"规划与循环执行"。
说起来有点抽象,但拆开看无非三件事:让模型在行动前思考,让模型在行动中观察,让模型在多次行动之间不忘记目标。这就是我们常说的Agent闭环,也是从"对话应用"升级到"Agent应用"的分水岭。
1.2 对话模型与Agent的分界线在哪里
我见过不少项目,目录里带着"Agent"几个字,实际实现就是"大模型 + 一个if-else工具分发器"。用户说"查天气",代码匹配到天气工具,调一下API,把结果塞回prompt,完事。这种方案不是不能用,但它缺少一个关键要素:模型没有决策权。
分界线其实很清楚。在普通的对话应用里,流程是模型死板的:
用户输入 → 由人类代码决定调用哪个工具 → 把工具结果模板化地拼进回复
而在真正的Agent应用里,流程变成了:
用户输入 → 模型自行判断需要哪些信息 → 模型选择工具并生成参数 → 执行工具得到结果 → 模型判断结果是否够用 → 不够就再来一轮
区别就在于"谁来决定下一步"。前一种方案里,决策链是工程师写死的;后一种方案里,模型在每一轮都参与决策。不要小看这个差别,它决定了你的应用能不能处理那些"你事先没想到"的情况。真实世界里的任务,步骤顺序往往是动态的——搜索某资料发现需要先登录,查报表发现需要先调另一个接口,这些动态应变只有"循环"才能做到。
所以我的建议是:如果你的工具调用场景固定、流程确定,那用带Function Calling的一次调用就够了,没必要上Agent架构;但如果你想做的是"给模型一个目标,让它自己想办法",那这篇讲的循环机制就是你绕不开的地基。
2. ReAct循环:给模型装上"眼睛"和"手"
2.1 为什么必须让模型先"想"再"做"
Agent领域有一个很经典的论文版本,提出了一种推理与行动交替进行的模式:Thought、Action、Observation,简称ReAct。核心逻辑特别简单:模型每走一步,要先输出自己的思考(Thought),再决定调用什么工具、传什么参数(Action),拿到工具返回的结果(Observation)后再进入下一轮思考。
为什么要这么设计?最早做Agent的人发现,如果让模型直接输出最终答案,它在需要多步推理的任务上表现很差。打个比方,你让一个人去陌生城市找一家餐厅,他不查地图不坐车,直接在原地闭眼猜"餐厅应该在这边",大概率是错的。但如果允许他边走边看路牌、看导航、问路,他就能一步步逼近目标。对模型来说,"思考"就是看路牌,"行动"就是迈步,"观察"就是确认新位置,三者循环交替,才能走完一条路。
从大模型原理上看,这也说得通。模型是逐token生成文本的,中间那些推理步骤本身就是"思考的痕迹"。当你要求它每一步都输出"Thought"时,它实际上在把工作记忆外露,每一步的推理结果都会成为后续生成的条件,最终答案的准确性自然会更高。这就是为什么"先想再做"比"直接做"效果稳定得多。
2.2 一个最简ReAct循环的代码骨架
在代码层面,一个ReAct循环其实非常朴素,核心就是一段循环:
def run_agent(prompt: str, tools: dict, max_steps: int = 8) -> str: """ 最简ReAct循环 tools: {"工具名": {"func": 可调用对象, "schema": JSON Schema}} """ messages = [{"role": "system", "content": SYSTEM_PROMPT}] messages.append({"role": "user", "content": prompt}) for step in range(max_steps): # 1. 调用大模型,让它"思考并决定下一步" response = call_llm(messages, tools=tools) # 2. 从回复里解析出动作:要么是final_answer,要么是工具调用 action = parse_llm_response(response) if action["type"] == "final_answer": return action["content"] # 3. 执行工具 tool_name = action["tool_name"] tool_args = action["tool_args"] try: tool_result = tools[tool_name]["func"](**tool_args) observation = str(tool_result)[:2000] # 截断,防止上下文爆炸 except Exception as e: observation = f"工具执行失败: {e}" # 4. 把模型回复和工具结果都追加到对话历史,进入下一轮 messages.append({"role": "assistant", "content": response["text"]}) messages.append({"role": "tool", "content": observation, "name": tool_name}) # 达到最大步数还没出结果,返回最后一次模型回复 return "抱歉,任务步骤太多未能完成,请尝试拆分为更小的任务。"这段代码里的call_llm你替换成自己用的模型SDK就好,parse_llm_response是解析层的核心。
解析层通常有两种做法。如果你用的是支持Function Calling的商用接口,模型会直接返回结构化的工具调用参数,解析很干净;如果你用的是普通开源模型,则要在System Prompt里约定输出格式,比如"必须输出JSON,格式为{"thought": "...", "action": "工具名", "action_input": {}}",然后用json解析。我个人建议,能用结构化模式就用结构化模式,正则匹配是下策,因为模型在生成自由文本时格式漂移太常见。
2.3 终止条件与异常兜底:最容易翻车的地方
刚开始写Agent循环的人,最容易忽略的是终止条件。没有终止条件的循环,好比一条没有刹车的路,模型可以永远"思考"下去,直到token烧完。
至少要有四道保险。
第一道是max_steps,就是上面代码里的循环上限。一般任务8步以内足够,超过8步的任务,大概率是因为任务描述太模糊或者工具设计太碎,硬跑下去只会越跑越偏。
第二道是"重复动作检测"。如果模型连续两三次调用同一个工具、传一样的参数、拿到一样的结果,说明它陷入了死循环。这时候应该主动打断它,往上下文里插入一条系统提示:"你刚才已经尝试过相同操作,结果相同,请更换策略。"
第三道是工具异常处理。我见过很多Agent,工具一抛异常整个流程就崩了。正确做法是把异常当观察结果返回给模型,让模型自己判断是换参数还是换工具。异常信息反而是很有价值的上下文。
第四道是输出格式兜底。模型有时会生成一堆解释文字而不是合法的JSON。一个简单的兜底是:如果解析失败,就把"解析失败,请严格按JSON格式输出"作为observation塞回去,让模型重试一次。实测有效的概率很高。
3. 任务规划进阶:从单步思考到Plan-and-Execute
3.1 单轮ReAct处理复杂任务时的局限
ReAct确实解决了"动态决策"的问题,但当任务变得复杂时,你会发现它有两个明显的毛病。
第一个毛病是"长链路漂移"。所谓漂移,就是模型在前几步决策还正常,到第五六步的时候已经忘记了最初的目标。这不能全怪模型,因为每一步生成时,它看到的上下文是逐渐累积的,早期目标和推理细节被越来越长的工具结果冲淡了。比如做一个竞品调研Agent,前三步还在查竞品A的信息,第五步突然去分析竞品B的招聘岗位了,方向就偏了。
第二个毛病是"串行浪费"。在ReAct的循环里,一步只能执行一个动作,模型思考下一步时,前面的步骤已经完成。假如你的任务里有些步骤是相互独立的(比如同时查三份不同资料),ReAct只能挨个来,速度慢且费token。
我遇到过最典型的场景:让一个Agent做"行业趋势分析",它第一步搜索行业报告,第二步去查政策文件,第三步查市场数据,这三个动作明明可以并行,但ReAct硬是一个一个来,耗时翻了三倍。
3.2 任务拆分的工程实现思路
Plan-and-Execute的思路很好理解:先让模型一次性生成一个计划列表,然后代码按计划执行,而不是让模型每走一步都想下一步。这就像带人干活,你先说清楚今天要做哪几件事,比做一步问一步效率高得多。
工程实现上,第一步是让模型输出一个JSON数组:
PLAN_PROMPT = """ 你是一个任务规划器。请把用户的目标拆解为可执行的步骤列表,要求: 1. 步骤数量控制在3到5个,每个步骤是一个可独立完成的子任务 2. 输出必须是JSON数组,每个元素包含step和description两个字段 3. 步骤之间尽量降低依赖,便于并行执行 请直接输出JSON,不要输出额外解释。 """得到计划后,执行阶段可以有两种做法。简单一点的,按顺序逐个执行,每个步骤用独立的ReAct子循环;复杂一点的,如果步骤没有依赖关系,可以用线程池并行执行,整体耗时能大幅缩短。很多框架里都是"规划器 + 执行器"这套结构,本质上就是这两种做法。
这里有个关键细节:虽然执行前有了计划,但计划未必百分百可靠。所以执行完一个步骤后,要把结果反馈给规划器,让规划器判断下一步是否需要调整计划。这个"计划-执行-反馈-调计划"的闭环,才是Plan-and-Execute的完整形态,否则遇到计划外情况就失灵了。
3.3 三种常见规划策略的对比与选型
我把用到的几种策略放在一张表里,方便你按项目情况选择。
| 策略 | 适用场景 | 优点 | 主要风险 |
|---|---|---|---|
| 单轮ReAct | 步骤不确定、依赖搜索结果的探索型任务 | 灵活,能动态应变 | 长任务易漂移,串行耗时 |
| Plan-and-Execute | 流程相对明确的批量任务 | 可控,可并行,节省token | 计划外情况需要反馈修正 |
| 层级规划(规划器+子Agent) | 任务跨度大、子任务类型完全不同 | 每个子任务用最合适的agent执行 | 实现复杂,调试成本高 |
选型上我的经验是:任务少于5步、变量多、需要反复试错,用单轮ReAct;任务超过5步但流程比较标准,用Plan-and-Execute;跨领域的复杂任务(比如"写一份产品发布方案",既要做市场调研又要写文案还要排计划),再用层级规划。
不要一上来就上"最强"的架构。层级规划听起来高大上,跑起来就知道痛苦:两个模型互相推诿、上下文串联复杂、排错时间成倍增长。能用简单策略解决的需求,就别给自己找麻烦。
4. 状态与记忆:让Agent在多轮任务中"不迷路"
4.1 Agent的多轮状态到底在维护什么
做Agent的人经常说"记忆",但"记忆"这个词太宽泛了,落到实处其实是几类不同性质的信息同时在工作。
第一类是任务目标。这是最重要的不变量,从一开始就固定下来,应该是整个上下文中优先级最高、最不会被淹没的信息。我建议在每轮对话中都重复注入目标摘要,哪怕这会多消耗一些token,换取的是模型不跑偏。
第二类是执行轨迹。已经做了哪些动作、每个动作的结果是什么,这类信息是动态产生的,量很大但价值密度不均。最新的几步结果最有用,早期的中间过程则逐渐失去参考价值。
第三类是用户偏好和事实信息。比如用户要求"报告要用中文、表格形式、不超过两页",这类偏好应该始终保留,不能被推挤掉。
实现上,我在项目里习惯用一个对象来管理这些状态字段:goal、steps_done、last_observations、user_prefs、raw_history。每次进入循环,先把goal和user_prefs拼进System Prompt,再把最近几步观测放进来,剩下的旧历史根据需要做摘要。
4.2 上下文压缩与记忆分级策略
现在主流的商用模型还有上下文窗口的限制(通常32k到200k不等),而一个复杂Agent跑下来产生的中间信息很容易冲破限制。所以记忆管理的核心工作,就是做"取舍"。
我常用的策略分三级:
第一级是精简保留。原始对话和工具结果,只保留最近的N条(比如6条)。
第二级是滚动摘要。第7到第N条的信息,每过几轮就让模型生成一段摘要,压缩为两三句话。摘要不是全部内容,但保留了关键结论。
第三级是长期记忆。更早的、与当前任务可能相关的信息,以摘要形式存到独立的存储里(比如向量库),需要时再检索引用。这就是第四篇讲的RAG在Agent里的应用方式。
一个非常朴素的实现如下:
class AgentMemory: def __init__(self, max_recent=6): self.max_recent = max_recent self.recent_history = [] self.summarized_history = [] def add(self, message: dict): self.recent_history.append(message) if len(self.recent_history) > self.max_recent: # 把最初的几条移出recent,做摘要 self._summarize() def _summarize(self): oldest = self.recent_history.pop(0) self.summarized_history.append(oldest) if len(self.summarized_history) >= 4: # 把这4条合并送给模型生成摘要 block = "\n".join([str(m) for m in self.summarized_history]) summary = call_llm(f"请总结以下执行记录中的关键事实,控制在50字内:\n{block}") self.summarized_history = [{"role": "summary", "content": summary}] def build_messages(self, system_prompt: str): return [{"role": "system", "content": system_prompt}] + \ self.summarized_history + self.recent_history这个实现虽然简单,但骨架是对的。真到生产环境,你还要考虑摘要的更新策略和并发访问锁的问题,但核心思想不会变:用最近消息保精度,用摘要压规模,用检索找长期记忆。
4.3 一个实用的状态恢复示例
Agent还有一个容易被忽略的工程问题:如果任务执行到一半,服务重启了,或者用户关掉了页面,这个任务还能不能接着跑?
答案是,只要状态能被持久化就能恢复。我在一个多章节写作Agent里做过类似设计:每执行完一个步骤,就把AgentMemory序列化成JSON存一份快照,包含当前目标、已完成步骤、摘要历史、最近记录。当用户再次打开页面,把快照加载回内存,告诉模型"你之前已经完成了以下步骤,请继续",任务就能原样续上。
def save_snapshot(memory: AgentMemory, file_path: str): import json snapshot = { "recent": memory.recent_history, "summarized": memory.summarized_history, "step": memory.finished_steps, } with open(file_path, "w", encoding="utf-8") as f: json.dump(snapshot, f, ensure_ascii=False)这个能力在普通对话应用里用不上,但在Agent应用里非常重要,因为一个真正的Agent任务可能要跑好几分钟甚至更长,用户不可能盯着一动不动。给他一个"暂停-恢复"的选项,体验会好很多。
5. 实战:用一个自动调研Agent串起全部环节
5.1 业务需求与整体设计
理论聊了这么多,不看个完整例子总是缺感觉。这个案例我做过类似的东西,场景很典型:用户输入一个行业关键词,Agent自动完成桌面研究,输出一份结构化调研报告。
拆解这个任务,需要的子能力包括:抓取网页摘要、按主题整理信息、生成对比表格、最后汇总成文。典型的步骤不确定任务——用户给的主题可能是新消费,也可能是工业软件,每一步要搜什么、去哪搜,提前无法预知。
我的整体设计:
- 外层采用Plan-and-Execute框架:规划器把"调研任务"拆成4个步骤,分别是综合搜索、竞品/玩家分析、市场数据收集、报告撰写。
- 每步内部采用ReAct循环:比如"综合搜索"这一步,Agent可能多次调用工具,先搜一次行业总览,发现内容不足再搜一次细分方向。
- 状态管理:由AgentMemory类统一维护,每完成一个子步骤就输出进度。
5.2 各模块实现的关键代码
调研工具我用的是一个简化的模拟搜索接口。实际项目里替换为你对接的搜索服务即可。
def search_web(keyword: str, page: int = 1) -> list: """模拟搜索接口,返回title+summary列表""" return [ {"title": f"{keyword}行业报告", "summary": "市场规模、主要玩家、增长趋势……"}, {"title": f"{keyword}政策动态", "summary": "相关政策和监管方向……"}, ]规划器生成计划,用之前提到的PLAN_PROMPT即可,只要把步骤聚焦到调研场景。执行器部分,每个步骤调用run_agent(第二节写的那个循环),并把工具限定成搜索、打开网页、提取要点这三个。
最后写报告这一步,我会额外加一个约束:报告必须基于前面收集的事实,不能自己编造数据。做法是在System Prompt里加上一句"你只能使用Observation中出现的信息,不要补充未出现的具体数字;如果某项数据缺失,标注'待补充'"。这一条看似简单,实测对报告质量影响巨大。很多Agent生成的报告看似完整,数据全是想象出来的,加了这条约束,至少每个数字都能追溯。
5.3 实测运行效果与参数调优
我拿"储能行业"当关键词跑过这个调研Agent,完整流程大概执行了9步模型调用,耗时约50秒(其中大部分时间是搜索接口等待)。最终报告包含了市场概况、主要玩家、政策与趋势三部分,格式完整,关键数字都能对得上搜索结果。
跑完几轮后,我做了几处参数调整,效果很明显。第一处是temperature:写报告时调到0.2,减少创造性发挥,确保内容忠实于检索信息。第二处是把max_steps从8改成10,因为调研类任务工具调用密集,8步经常卡在最后一步。第三处是搜索结果的截断长度,从2000字改成800字,因为搜索摘要本身就很精简,塞太多重复信息反而干扰模型判断。
这里还想提醒一个容易被忽略的点:工具返回的结果最好先做一遍"清洗"。搜索接口返回的网页摘要经常带HTML标签和无关的推广内容,直接塞给模型会污染上下文。我在工具层加了一个简单的清洗函数,去掉标签和空行,让输入给模型的每一步观察都尽量干净和结构化。
6. 落地生产前的几个重要提醒
6.1 延迟控制的现实约束
Agent和普通API调用的体验差异,最直观的体现就是"慢"。一个复杂Agent跑一次可能产生十几次模型调用,每一次都是秒级延迟,用户很可能等得不耐烦。
想改善延迟,无非从三个方向下手:并行、提前输出、减少调用次数。
并行是最有效的。Plan-and-Execute里如果步骤没有依赖关系,就并发执行,整体耗时可能从50秒降到20秒。但并行会带来一个问题:多个步骤同时写内存,需要做好锁控制,别让两个子任务把对方的结果覆盖了。
提前输出也很实用。不要让用户傻等一个空白页面,至少展示当前进度:"正在执行第三步:分析市场数据……",让用户知道系统在干活。这既是体验优化,也是工程上常见的"可见性"原则。
减少调用次数,就要回到选型了。能用一次模型调用解决的需求,绝不要套Agent循环。我现在遇到很多项目的问题不是"Agent不够聪明",而是"根本不该用Agent的地方硬用了Agent"。
6.2 模型选型建议与成本控制
模型的选择直接决定了Agent表现的上限和成本的下限,这部分我没有标准答案,但有三条经验可以分享。
第一,规划器用更强的模型,执行器可以用相对弱的模型。规划器做的是全局判断,需要强推理能力;执行器面对的是具体、明确的子任务,弱一点的模型只要遵循指令做到"听话"就够了。这套组合拳能省不少成本。
第二,对于工具调用,尽量选支持结构化输出的模型。解析自由文本的JSON格式,既费token又不稳定。结构化模式不仅省掉了解析纠错成本,还让工具参数的类型校验变得可靠。
第三,做成本预估时,一定要把Agent的"多轮放大效应"算进去。你以为一次调研任务花了多少token,实际可能是你预估的5到10倍,因为每一轮的思考、工具结果、历史记录都在消耗。我在生产环境里给Agent加了一个token计数器,每次运行后打点记录,用几天的数据再来反推单个任务的预算。
6.3 我个人踩过的三个坑
最后聊几个我在实际项目中踩过的坑,希望你不用再踩一遍。
第一个坑是工具参数的"类型幻觉"。模型调用工具时,经常把本该是整型的参数传成字符串"123",或者把本该是数组的参数传成JSON字符串。后来我在工具调用层统一加了一层schema校验和类型转换,凡是字符串形式的数字都自动转int,无效参数直接返回错误信息让模型重试。别指望模型每次都生成完美参数,防御性编程在这里是必需品。
第二个坑是上下文里塞了太多"工具日志"。最开始我把每次工具调用的完整HTTP响应都塞回给模型,没做截断和清洗,结果模型被一堆无意义日志干扰,开始关注错误的细节。后来我养成了习惯:所有工具返回先清洗、截断、提取要点,再进上下文。Observation应该是"要点摘要",而不是"原始返回"。
第三个坑是"无限重试陷阱"。我给Agent加过重试逻辑,工具失败了就让它重试,结果遇到系统性故障(比如搜索接口连续宕机),Agent会不停重试同一操作,直到跑满max_steps,浪费了大量token。现在我的做法是:同一个工具连续失败两次,就切换策略——或者更换工具,或者让模型明确告诉用户"该数据源不可用,需要跳过还是改用备选方案"。
这三件事看起来都是细节,但Agent项目里这些细节往往比模型选型更能决定成败。很多人觉得Agent的难点在于"让模型理解任务",做久了才发现,真正的难点在于"让任务在充满不确定性的环境里稳定跑完"。把握好循环、状态、容错这三件事,你的Agent应用基本就能从Demo走向可用了。