手写一个最小 ReAct 循环:退出条件和死循环兜底怎么设计?
谁在问:一二面手写/白板环节高频;面试官用它验证「用过框架」还是「写过循环」
口语化问法
- 不用框架,你把一个 ReAct 循环的骨架写出来,二十行就行。
- 你这个循环什么时候停?只靠模型说『我答完了』够吗?
- 如果它一直在调同一个工具,你怎么发现、怎么处理?
考察意图
这题是手感的终极探针。用框架的人能讲清 ReAct 是"推理—行动—观察"的循环,但写不出退出条件;写过循环的人第一反应就是"上限放哪、异常往哪吞、兜底返回什么"。面试官在看四件事:
- 循环的"一步"定义对不对——一步是一次模型请求,不是一次工具执行。这决定了你怎么算成本和时延。
- 退出条件是不是只有一个——只写 max_steps 的人没上过线。
- 异常怎么处理——把异常抛出循环,Agent 就废了;把堆栈原样回灌,成本和安全都出问题。
- 上限触发之后返回什么——报错、还是降级返回半成品加未完成项,差别就是产品能不能用。
参考答案
60 分答案(及格线)
ReAct = Reason(模型想下一步做什么)+ Act(执行工具)+ Observation(结果回灌),循环直到出结果。骨架:
messages = [system_prompt, user_input]
for step in range(max_steps):
resp = llm.chat(messages, tools=tools) # 一步 = 一次模型请求
messages.append(resp.message) # 别忘了把带 tool_call 的这条也放回去
if not resp.tool_calls: # 正常出口
return resp.text
for call in resp.tool_calls:
result = execute(call) # 执行在我这边
messages.append(tool_result(call.id, result))
return "达到步数上限" # 兜底出口两个退出条件:模型返回不带工具调用的最终答案(正常);达到 max_steps(兜底)。死循环最常见的形态是模型反复用相同参数调同一个工具,可以对"工具名 + 参数"做指纹计数,重复超过阈值就中断。
90 分答案(有生产经验的回答)
在骨架上补四件生产必需品:预算、指纹、异常吞掉、降级出口。
def run(user_input, tools, max_steps=8, budget=Budget(tokens=60_000, seconds=60, cny=0.5)):
messages = [system_prompt(), user(user_input)]
seen = Counter() # 动作指纹计数
for step in range(max_steps):
if budget.exhausted(): # 出口 2:预算/超时
return degrade(messages, reason="budget")
resp = llm.chat(messages, tools=tools)
budget.charge(resp.usage)
messages.append(resp.message)
if not resp.tool_calls: # 出口 1:正常终止
return finalize(resp.text, messages)
for call in resp.tool_calls:
fp = fingerprint(call.name, call.args) # 参数排序+归一化后哈希
seen[fp] += 1
if seen[fp] >= 3: # 出口 3:死循环干预
messages.append(tool_result(call.id,
"你已用相同参数调用该工具 3 次,结果不会变。"
"请改换参数、改用其他工具,或基于现有信息作答。"))
continue
ok, result = safe_execute(call) # 异常绝不抛出循环
messages.append(tool_result(call.id, clip(result)))
return degrade(messages, reason="max_steps") # 出口 4:步数上限要讲清的四个设计点:
1. 退出条件有四类,不是一类。 正常终止(无 tool_call)/资源终止(步数、时间、token、费用)/异常终止(工具连续失败熔断、护栏拦截、用户中断)/无进展终止(连续几步没有新增信息)。前两类大家都会写,后两类是分水岭。
2. 兜底不是抛异常,是 degrade()。 触顶时返回三段:已经拿到的信息、还没完成的事、建议的下一步(换个问法/转人工)。直接抛"达到步数上限"在产品上等于白跑一次还惹恼用户。
3. 异常必须在 safe_execute 里被吞掉,并翻译成可操作的话。 堆栈回灌有三个问题:占 token、模型看不懂所以不会改、可能泄漏表名路径等内部信息。正确做法是区分可重试(超时、限流——在代码里退避重试,不打扰模型)和不可重试(参数非法、无权限——翻译成"参数 date 需 YYYY-MM-DD"回灌让模型自己改),连续失败则熔断降级。
4. 每一步都要留痕。 至少记录:步号、工具名、参数摘要、结果长度、耗时、token、终止原因。终止原因的分布是最便宜的健康度指标——正常终止占比掉、max_steps 触发率涨,说明任务变难或提示词退化了,比看用户投诉早很多。截至 2026-08,OTel GenAI 语义约定 v1.41 给了 invoke_agent → chat → execute_tool 的嵌套 span 结构和 token 指标口径,可以直接对齐(多数属性仍是实验性,内容捕获默认关闭,合规上反而合适)。
5. 一句时效判断。 早期 ReAct 是文本协议——让模型输出 Thought/Action/Action Input,代码用字符串解析。截至 2026-08 这套只在不支持原生工具调用的模型上才用,主流一律走原生 function calling,省掉脆弱的解析层。面试时说得出这个演进,比只会背论文结构强。
三段式看 ReAct 本身:解决的是"下一步依赖上一步观察"的任务;代价是每步一次模型请求,时延与成本随步数线性增长、错误按乘法累积、轨迹不可复现;不该用在路径固定(写 workflow)和需要长程规划的场景(考虑 Plan-and-Execute,见 Q3-07)。
追问链
max_steps你设多少?依据是什么?期望先量分布:跑真实任务取成功案例步数的P95加余量,业务型多在个位数;设到 20、30 是用上限掩盖任务拆得不对。更好的是按意图分档(简单查询 3 步、复杂排查 10 步)。步数不是唯一闸——一步几百到几万 token,得叠 token/时间/费用预算先到先熔断信号说「先量分布,且步数之外还要有预算闸」→ 做过线上治理;张口报「一般设 10」说不出依据 → 抄的默认值怎么检测死循环?除了相同调用计数还有别的信号吗?
期望① 动作指纹先归一化(参数键排序、去空白、时间统一)再比,否则多打个空格就绕过;② 结果指纹:参数不同返回一样即原地打转;③A-B-A-B交替;④ 无进展:连续几步无新实体新数值;⑤ 输出自相似度过高。触发后先注入干预消息再中断,直接杀一定失败信号提「指纹要归一化」「先干预再中断」→ 被真死循环坑过;只答「计数超 N 次就停」→ 想象出来的方案工具抛异常怎么处理?直接把堆栈回灌行不行?
期望不行:占 token、模型看不懂、泄漏表名路径。要分层——可重试的(超时、限流)在代码里退避重试,模型无感;不可重试的翻译成「参数date需YYYY-MM-DD」这类可操作文本回灌;同一工具连续失败熔断。错误文案改可操作后,自愈成功率是能量出来的信号区分可重试/不可重试并提泄漏风险 → 写过生产代码;答「try-catch 一下把错误告诉模型」不分类型 → 及格线上下这个循环怎么调试?线上怎么定位是哪一步错的?
期望一次 run 一条trace,每步chatspan 套toolspan,记工具名、参数、耗时、token、终止原因。定位分层:先看终止原因(正常/步数/预算/熔断)→ 再看轨迹形态(选错工具、参数错、返回空)→ 最后才看文本;messages全序列留存以便重放信号给出「先看终止原因 → 再看轨迹形态 → 最后看文本」并提重放 → 排查过真问题;只答「打日志」→ 没有方法论线上一个请求跑了 40 多步、单次烧掉几十块钱,用户还没拿到答案。怎么止血、之后怎么防?
期望止血分钟级:① 先上硬闸(max_steps+ 费用预算)→ ② 该意图降级到固定管线或转人工 → ③ 单会话限流。再查trace分形态:同工具重复 → 指纹拦截;两工具互绕 → 依赖拆解;返回空 → 补下一步提示。防复发:触发率、P99 步数、成本告警 + per-request 预算,路径固定的固化成workflow。砍太狠伤成功率,得盯触发率信号先上硬闸再查根因(顺序对)+ 按轨迹形态分类修法 + 给出触发率这个反向观测 → 负责过线上;只答「加个 max_steps」→ 扣分
评分要点
- 循环骨架正确:一步 = 一次模型请求,工具执行在代码侧
- 记得把带 tool_call 的 assistant 消息 append 回历史(否则 id 配对断裂)
- 退出条件至少三类:正常终止、资源上限、异常/死循环
- 步数上限之外还有 token/时间/费用预算
- 死循环检测用归一化指纹,且先干预后中断
- 异常不外抛,翻译成可操作的错误回灌,区分可重试与不可重试
- 触顶返回降级结果(已得信息 + 未完成项 + 下一步),不是报错
- 加分:每步留痕,用「终止原因分布」当健康度指标
- 加分:知道原生 FC 已取代文本解析式 ReAct(截至 2026-08)
常见错误
while True 加 max_steps,没有任何其他闸——最典型的 demo 级答案。