ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

048、手工实现ReAct Agent代码详解

048、手工实现ReAct Agent代码详解 048、手工实现ReAct Agent代码详解那天半夜线上告警一个用Langchain搭的Agent突然开始疯狂循环调用一个工具日志刷得飞快token费用肉眼可见地飙升。我顺手把堆栈拉下来一看好家伙模型在思考里反复写“我需要调用search工具获取天气”然后真的调了十几次同样的参数每次结果都一样它还在那儿“观察”然后继续调。当时我就想把框架拆了看看到底是哪层逻辑把模型带沟里去了。后来等我真正手工搓了一个ReAct Agent才明白问题出在哪——框架封装得太好把“思考、行动、观察”的循环变成了一个黑盒你以为它在推理其实它只是在机械地重复字符串匹配。从那天起我决定所有Agent核心循环都自己写再难也不怕。要理解ReAct别急着看论文先看一段最朴素的伪代码逻辑模型拿到一个问题输出一段文字里面要么包含“思考”要么包含“行动”如果行动了就把工具结果拼回去让模型继续直到它说“最终答案”。就这么简单。但真写起来坑全在细节里。先定义工具。别用什么pydantic花活就一个字典key是工具名value是个函数。我早期踩过一个坑把工具函数当成类方法写导致self传进去模型返回的参数总是错位。正确做法是普通函数参数就是字符串让模型自己处理JSON。你问我为什么不用结构化输出因为ReAct的本质是自由文本推理你一旦强约束JSON schema很多小模型就傻了反而不如让它直接写“工具名(参数)”这种自然格式。defsearch(query:str)-str:# 这里别真去调百度容易封IP先写个假的returnf搜索结果:{query}的相关信息注意这里返回的是纯文本defcalculator(expr:str)-str:# 安全起见用eval得先过滤别直接裸奔safe_charsset(0123456789-*/(). )ifnotall(cinsafe_charsforcinexpr):return错误表达式包含非法字符try:returnstr(eval(expr))exceptExceptionase:returnf计算错误:{e}TOOLS{search:search,calculator:calculator,}工具返回的是字符串这一点很重要。为什么因为你要把观察结果直接拼回prompt里任何非字符串类型都得转成字符串而且越短越好。我曾经让工具返回一个巨大的JSON结果模型下一步的“思考”全在复述JSON内容完全忘了原始问题。工具输出要像人话别给模型丢一堆结构化数据让它自己解读。这个细节在调试时能省一半token。接下来是构造提示词。网上很多教程直接抄ReAct论文里的模板但实践下来你得把工具列表和样例写进system prompt里否则模型不知道怎么格式化。我的模板长这样这里踩过一个坑不要用中文冒号模型有时会跟英文冒号混一起导致解析失败。统一用英文冒号加换行。SYSTEM_PROMPT你是一个智能体通过以下工具解决问题。请严格按如下格式输出 思考: 你的推理过程 行动: 工具名称(参数) 观察: 工具结果 重复以上步骤 思考: 现在我知道答案了 最终答案: 你的答案 可用工具 search(query): 搜索 calculator(expr): 计算数学表达式 注意 1. 每次只能输出一个行动等待观察结果。 2. 参数必须是合法JSON字符串例如 search(北京天气) 3. 如果不需要工具直接输出最终答案。 注意第2条我让参数必须是合法JSON字符串但很多模型会写成search(北京天气)少了引号。所以我干脆在解析时加一层纠错别对模型太苛刻。下面这段解析代码是我被坑了十几次后才写出来的特别注释一下importre,json,astdefparse_action(text):# 正则匹配行动: 工具名(参数)mre.search(r行动: (\w)\((.)\),text,re.DOTALL)ifnotm:returnNone,Nonetool_namem.group(1)raw_argsm.group(2).strip()# 这里注意模型可能输出带转义引号比如 {\query\: \北京\}# 先把外层的单引号或双引号去掉再尝试json解析try:# 尝试用ast.literal_eval安全解析比eval安全argsast.literal_eval(raw_args)ifisinstance(args,str):returntool_name,{query:args}# 单一参数工具统一转成dictifisinstance(args,tuple):returntool_name,dict(zip(get_params(tool_name),args))except:pass# ast失败试试jsontry:argsjson.loads(raw_args)returntool_name,argsexcept:pass# 最后兜底如果raw_args看起来只是个纯字符串直接当query用# 别这样写但真没招了模型总不听话只能惯着它ifraw_args.startswith()andraw_args.endswith():raw_argsraw_args[1:-1]elifraw_args.startswith()andraw_args.endswith():raw_argsraw_args[1:-1]returntool_name,{query:raw_args}这个解析函数我写了三个版本第一个版本直接eval被模型传入的恶意代码试试看第二个版本用json.loads但模型总多打一个逗号。第三个版本就是上面这样先用ast.literal_eval再json最后纯字符串兜底。你可能会问get_params这个函数哪来的我本来想动态获取工具函数的参数名但后来发现太复杂干脆所有工具参数都叫query少一个变量。真的统一参数名能减少一半解析bug。然后就是主循环。这里最容易犯的错误是用递归递归写起来优雅但遇到长对话直接栈溢出。我一开始就是递归写的跑了个五轮对话就RecursionError。改成while循环稳如老狗。我自己写的循环如下defrun_agent(query,max_steps5,call_llm...):messages[{role:system,content:SYSTEM_PROMPT},{role:user,content:query}]step0whilestepmax_steps:# 调用模型拿到回复responsecall_llm(messages)print(模型输出:\n,response)# 如果回复里有最终答案就收工if最终答案:inresponse:answerresponse.split(最终答案:)[-1].strip()returnanswer# 否则解析行动tool_name,argsparse_action(response)iftool_nameisNone:# 模型瞎扯了没按格式来得把它拉回来# 这里别直接报错把提示词再压一遍messages.append({role:assistant,content:response})messages.append({role:user,content:你的回答缺少行动:字段请重新按格式输出。只输出一步。})step1continueiftool_namenotinTOOLS:messages.append({role:assistant,content:response})messages.append({role:user,content:f工具{tool_name}不存在可用的工具:{list(TOOLS.keys())}重新输出。})step1continue# 执行工具try:observationTOOLS[tool_name](**args)exceptExceptionase:observationf工具执行出错:{e}# 把模型的思考行动观察拼回去作为新的上下文# 注意messages里要保留模型原始输出再追加一条带观察的user消息messages.append({role:assistant,content:response})messages.append({role:user,content:f观察:{observation}\n请继续输出下一步思考。})step1# 步数超限返回最后的模型输出或者一个默认值return达到最大步数未得到答案。看到没我把“观察”放在user消息里还加了一句“请继续输出下一步思考”。为什么因为如果你只拼一个“观察: xxx”模型可能以为这是用户的下一句话上下文衔接容易错乱。加个指令词模型就能很清楚自己该干嘛。这里踩过一个小坑如果你把assistant消息里同时包含“思考”和“行动”然后user消息里只有“观察”有些API会报错因为OpenAI的消息角色交替assistant后必须跟user或tool没问题但有些本地小模型会混乱。我干脆在user消息里把观察和指令写清楚这样最保险。还有一个致命问题max_steps设多少我默认5但真实场景里如果工具返回的信息还需要二次检索5步根本不够。我见过一个Agent为了查一个复杂的法律问题连续调了12次搜索。所以max_steps要可配置而且每步的token要限制防止模型输出超长废话。我在call_llm里做了截断如果模型输出超过1000字直接切掉只留最后一段。为什么留最后因为模型通常在末尾输出行动。关于call_llm这个函数我没贴全因为不同用户用的后端不一样。但核心是要返回纯文本内容。如果你用OpenAI接口记得把response_format设置成Text别用JSON模式。JSON模式会强制模型输出JSON反而干扰行动格式。我踩过这个坑开着JSON模式跑了半天模型只会输出{“action”: “search”}不会输出思考过程ReAct直接退化成普通函数调用。测试上面这套代码得跑个例子。我把openai换成模拟模型手工模拟输入输出发现一个很有趣的现象当模型输出“思考: 需要计算12345”之后行动写的是calculator(12345)但解析成query参数后ast.literal_eval把字符串内的*当成乘号导致传入参数是整数乘法结果。这个问题出在literal_eval对字符串的解析上。你调用parse_action(“calculator(12345))raw_args是12345”ast.literal_eval(“123*45”)会返回一个整数5535因为Python会计算表达式。这当然不是我们想要的我们想把原始表达式传给计算器。这才发现literal_eval并不安全它虽然不能执行任意代码但会执行算术表达式。正确做法是先判断如果raw_args看起来不是合法的Python字面量就直接当字符串用。看下面修改后的parse_action关键部分# 在ast.literal_eval前先检查括号配对和非法字符importast unsafe_charsset(;|$\\)ifany(cinunsafe_charsforcinraw_args):returntool_name,{query:raw_args}# 不干净直接当字符串try:# 只有以引号开头才尝试字面量避免123*45被计算ifraw_args[0]in(,):argsast.literal_eval(raw_args)else:argsraw_args# 无引号直接字符串...except:returntool_name,{query:raw_args}那个unsafe_chars过滤是后来加的防止模型通过参数注入特殊字符。虽然我们只用eval在计算器里但search函数可能拼接shell命令万一模型输入了; rm -rf /那画面太美。所以任何外部输入都必须当字符串处理不能信模型的格式化。主循环还有个细节工具执行结果要截断。比如search返回一个超长网页模型读太多上下文会忽略关键信息。我在观察结果加了长度限制超过300字就切掉并在末尾加“…已截断”。这个截断不是随意的得保留开头和结尾因为关键数字通常在开头。但事实上很多搜索API返回的结构化信息开头都是一堆没用的JSON key真正有用的在中间。后来我改成用正则提取正文中最长的连续中文段落但这就偏离主题了。简单方案截取前200字符加上后100字符中间省略号。这个办法治标不治本但至少线上不崩。最后我还想提一个很多人忽略的点Agent的停止条件。除了“最终答案”出现还有一个隐晦的停止条件——当模型连续多步重复输出相同思考时你就该掐断它。因为模型陷入了死循环。我在循环里加了一个计数器repeat_count如果模型输出的“思考”部分与上一步完全一样就认为卡住了然后手动注入一条警告“你已经重复了相同思考尝试别的方向。”这个办法立功无数次。别问为什么模型会重复因为训练数据里多轮推理常见但模型没有“自我纠正”的机制只能靠外部拉一把。顺便说一句debug时别用真LLM先用一个假模型脚本让它在指定的步骤返回预定义的字符串。这样你能把Agent的循环逻辑调通再替换成真模型。否则你一边调prompt一边调解析出了问题你根本不知道是模型的问题还是代码的问题。很多时间浪费在这上面。最后给你一个经验永远不要让Agent的循环体超过20行代码。如果超过了说明你把决策逻辑写进了循环里比如条件分支、异常重试、上下文裁剪这些应该提取成独立函数。循环体只做四件事调用模型、解析行动、执行工具、拼接观察。任何额外逻辑都塞到工具或解析层里。我的主循环就是上面那段没超过30行但足够稳定。还有一条更私人的经验所有工具的返回值尽量以“某年某月某日”开头。比如搜索天气返回“2024年5月20日北京晴”。为什么因为模型在处理时间敏感信息时如果工具结果里没有日期它经常会编造一个“今天”实际上跟事实差了十万八千里。给工具输出加上时间戳能遏制模型的幻觉。这个技巧是我看一个老外的bug report学到的亲测有效。你要真想在生产环境用这个手工Agent还得加缓存。工具调用结果缓存同一个query在短时间内重复调用直接返回旧结果避免浪费token。这不在本篇范围内但如果你已经实现到这一步说明你已经能自由控制Agent的核心逻辑后面加缓存、加记忆、加工具注册机制都是水到渠成。我写这篇博客的时候特意没有用“首先/然后/最后”这些词因为写代码本身就是一步接一步的调试没有线性顺序只有血泪教训。ReAct Agent不难难的是你愿意从黑盒里走出来亲手把每一个解析异常打出来看。希望这篇笔记能让你少走几个坑。
返回列表