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

Q3-06ReAct 循环实现高频

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

开场怎么问

不用框架,你把一个 ReAct 循环的骨架写出来,二十行就行。

换个问法

  • 你这个循环什么时候停?只靠模型说『我答完了』够吗?
  • 如果它一直在调同一个工具,你怎么发现、怎么处理?

五层追问链

左边照着问,右边对着听。最后一层是压力面,不必每个候选人都问到。

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

期望
不能拍脑袋。做法是先跑一批真实任务,统计成功案例的步数分布,取 P95 再加一点余量——大多数业务型任务的 P95 在个位数,设到 20、30 通常说明任务拆得不对或工具粒度太细,是在用上限掩盖设计问题。更好的是按意图分档(简单查询 3 步,复杂排查 10 步)。还要强调步数不是唯一闸:一步的代价方差很大(一次调用可能几百 token 也可能几万),所以必须叠加 token/时间/费用预算,先到先熔断。
信号
说「先量分布再定,且步数上限之外还要有预算闸」的,做过线上治理;直接报一个数字(「一般设 10」)说不出依据的,是抄的默认值。

怎么检测死循环?除了对相同调用计数还有别的信号吗?

期望
① 动作指纹要归一化再比(参数键排序、去空白、时间表述统一),否则模型换个空格就绕过检测;② 结果指纹——工具参数不同但返回内容一样,说明在原地打转;③ A-B-A-B 交替模式检测(两个工具互相等对方结果是经典形态);④ 无进展检测:连续几步没有新增有效信息(新实体、新数值)就判定空转;⑤ 模型输出自相似度过高。触发之后的处理很关键:优先注入干预消息(告诉它已重复、给出可选动作),而不是直接杀——很多时候一句干预就能让它换路径,直接杀掉则一定失败。
信号
提到「指纹要归一化」「先干预再中断」的,被真死循环坑过;只答「计数超过 N 次就停」的,是想象出来的方案。

工具抛异常时怎么处理?直接把堆栈回灌行不行?

期望
不行,三个理由(占 token、模型不可操作、泄漏内部信息)。正确分层:先区分可重试与不可重试;可重试的在代码里带退避重试,重试成功模型完全无感;不可重试的翻译成可操作错误文本回灌,说明哪个参数不对、期望什么格式、有没有替代工具。同一工具连续失败要熔断(本轮内不再提供该工具或直接降级),否则模型会在它身上耗光步数。加分点:错误文案本身要评测——把报错文案改可操作之后,"自愈成功率"是能量出来的。
信号
区分可重试/不可重试并且提到泄漏风险的,写过生产代码;答「try-catch 一下把错误告诉模型」但不区分类型的,及格线上下。

这个循环怎么调试?线上出问题怎么定位是哪一步错的?

期望
一次 run 一条 trace,每步一个 chat span 套若干 tool span;字段至少有工具名、参数摘要、结果长度、耗时、token、终止原因。定位方法论:先看终止原因(正常/步数/预算/熔断),再看轨迹形态(是选错工具、参数错、还是工具返回了空),最后才看具体文本。保存完整 messages 序列以便离线重放——不可复现是 Agent 调试最大的敌人,能重放就能把一个偶发问题变成一条固定测试用例。合规上注意内容捕获要可开关、要脱敏。
信号
给出「先看终止原因分布,再看轨迹形态,最后看文本」这种分层定位法、并提到重放的,排查过真问题;只答「打日志」的,没有方法论。

线上有个请求跑了 40 多步、单次烧掉几十块钱,用户最后还没拿到答案。现场怎么止血?之后怎么防?

期望
止血按分钟级排优先级:① 立刻上硬闸——如果连 max_steps 和费用预算都没有,这就是第一优先级,先把上限压到保守值发上去;② 对该意图临时降级到固定管线或转人工,先保住用户体验;③ 单用户/单会话限流,防止同一个坏 case 被反复触发放大损失。 同时查 trace 定形态:40 步里是同工具重复、两工具互绕、还是工具一直返回空导致模型不断换措辞?三种形态的修法完全不同(分别是指纹拦截、依赖关系拆解、工具空结果要带下一步提示)。 防复发要落到埋点:max_steps 触发率、P99 步数、单次成本 P99 三个指标上告警;per-request 费用预算强制生效;把这条 case 加进回归集;如果这类请求本来就路径固定,直接固化成 workflow 别让它进 Agent(见 Q3-02)。 取舍要说清:max_steps 砍得太狠会让复杂请求成功率下降,所以不是砍完就完事——要观察触发率,触发率高说明砍过头或者这类任务根本不适合 Agent,两种结论都得用数据说。 体验兜底:给用户可见进度和可中断按钮,别让人干等 40 步。
信号
先上硬闸再查根因(顺序对)、能按轨迹形态分类修法、给出触发率这个反向观测指标的,是负责过线上的;只答「加个 max_steps」(没有预算闸、没有告警、没有回归)或者一上来就深入分析根因不先止血的,都扣分。

危险信号

听到这些话,基本可以判定是背题而不是做过。

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

评分卡

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

考察意图

这题是手感的终极探针。用框架的人能讲清 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)。

攒够了去组卷页一键生成可打印的面试题单