手写一个最小 ReAct 循环:退出条件和死循环兜底怎么设计?

Q3-06ReAct 循环实现高频ReAct循环控制退出条件死循环预算熔断降级trace

谁在问:一二面手写/白板环节高频;面试官用它验证「用过框架」还是「写过循环」

口语化问法

  • 不用框架,你把一个 ReAct 循环的骨架写出来,二十行就行。
  • 你这个循环什么时候停?只靠模型说『我答完了』够吗?
  • 如果它一直在调同一个工具,你怎么发现、怎么处理?

考察意图

这题是手感的终极探针。用框架的人能讲清 ReAct 是"推理—行动—观察"的循环,但写不出退出条件;写过循环的人第一反应就是"上限放哪、异常往哪吞、兜底返回什么"。面试官在看四件事:

  1. 循环的"一步"定义对不对——一步是一次模型请求,不是一次工具执行。这决定了你怎么算成本和时延。
  2. 退出条件是不是只有一个——只写 max_steps 的人没上过线。
  3. 异常怎么处理——把异常抛出循环,Agent 就废了;把堆栈原样回灌,成本和安全都出问题。
  4. 上限触发之后返回什么——报错、还是降级返回半成品加未完成项,差别就是产品能不能用。

参考答案

图 2 · 60 分与 90 分差在哪:代价、演进、怎么验证

60

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

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)。

追问链

图 1 · 五层追问树:面试官会往哪儿挖
五问都绕着一件事:这个循环凭什么停、停下来给什么

  1. max_steps 你设多少?依据是什么?

    期望先量分布:跑真实任务取成功案例步数的 P95 加余量,业务型多在个位数;设到 20、30 是用上限掩盖任务拆得不对。更好的是按意图分档(简单查询 3 步、复杂排查 10 步)。步数不是唯一闸——一步几百到几万 token,得叠 token/时间/费用预算先到先熔断
    信号说「先量分布,且步数之外还要有预算闸」→ 做过线上治理;张口报「一般设 10」说不出依据 → 抄的默认值
  2. 怎么检测死循环?除了相同调用计数还有别的信号吗?

    期望① 动作指纹先归一化(参数键排序、去空白、时间统一)再比,否则多打个空格就绕过;② 结果指纹:参数不同返回一样即原地打转;③ A-B-A-B 交替;④ 无进展:连续几步无新实体新数值;⑤ 输出自相似度过高。触发后先注入干预消息再中断,直接杀一定失败
    信号提「指纹要归一化」「先干预再中断」→ 被真死循环坑过;只答「计数超 N 次就停」→ 想象出来的方案
  3. 工具抛异常怎么处理?直接把堆栈回灌行不行?

    期望不行:占 token、模型看不懂、泄漏表名路径。要分层——可重试的(超时、限流)在代码里退避重试,模型无感;不可重试的翻译成「参数 dateYYYY-MM-DD」这类可操作文本回灌;同一工具连续失败熔断。错误文案改可操作后,自愈成功率是能量出来的
    信号区分可重试/不可重试并提泄漏风险 → 写过生产代码;答「try-catch 一下把错误告诉模型」不分类型 → 及格线上下
  4. 这个循环怎么调试?线上怎么定位是哪一步错的?

    期望一次 run 一条 trace,每步 chat span 套 tool span,记工具名、参数、耗时、token、终止原因。定位分层:先看终止原因(正常/步数/预算/熔断)→ 再看轨迹形态(选错工具、参数错、返回空)→ 最后才看文本;messages 全序列留存以便重放
    信号给出「先看终止原因 → 再看轨迹形态 → 最后看文本」并提重放 → 排查过真问题;只答「打日志」→ 没有方法论
  5. 线上一个请求跑了 40 多步、单次烧掉几十块钱,用户还没拿到答案。怎么止血、之后怎么防?

    期望止血分钟级:① 先上硬闸(max_steps + 费用预算)→ ② 该意图降级到固定管线或转人工 → ③ 单会话限流。再查 trace 分形态:同工具重复 → 指纹拦截;两工具互绕 → 依赖拆解;返回空 → 补下一步提示。防复发:触发率、P99 步数、成本告警 + per-request 预算,路径固定的固化成 workflow砍太狠伤成功率,得盯触发率
    信号先上硬闸再查根因(顺序对)+ 按轨迹形态分类修法 + 给出触发率这个反向观测 → 负责过线上;只答「加个 max_steps」→ 扣分
一句「一步 = 一次模型请求」只够拿第 1 层的门票。第 3 层和第 5 层才见真章:异常吞在哪一层,以及四十步烧完钱之后先止血还是先归因。

评分要点

  1. 循环骨架正确:一步 = 一次模型请求,工具执行在代码侧
  2. 记得把带 tool_call 的 assistant 消息 append 回历史(否则 id 配对断裂)
  3. 退出条件至少三类:正常终止、资源上限、异常/死循环
  4. 步数上限之外还有 token/时间/费用预算
  5. 死循环检测用归一化指纹,且先干预后中断
  6. 异常不外抛,翻译成可操作的错误回灌,区分可重试与不可重试
  7. 触顶返回降级结果(已得信息 + 未完成项 + 下一步),不是报错
  8. 加分:每步留痕,用「终止原因分布」当健康度指标
  9. 加分:知道原生 FC 已取代文本解析式 ReAct(截至 2026-08)

常见错误

只写 while True 加 max_steps,没有任何其他闸——最典型的 demo 级答案。
忘记把模型那条带 tool_call 的消息放回历史,只放工具结果——线上会直接报 id 不匹配。
把工具异常直接抛出循环,一个超时就让整个 Agent 挂掉。
把堆栈原样回灌给模型,既贵又没用还可能泄漏内部信息。
触顶后返回「已达最大步数」了事,不给用户任何有效产出。
「死循环就把 max_steps 调小」——治标,且会伤害复杂请求的成功率。
说得出 Thought/Action/Observation 三个词,但讲不出任何一个退出条件——纯背论文。
完全不提可观测,问「怎么定位」只答「看日志」。

关联学习