Skip to content

第 6 章 · 循环:简单循环 + 显式护栏

pixiu 锚点:agent/core.py:_continue_agent_loop(ReAct 循环 + 三层护栏)、agent/scene.py(场景路由) 关键案例:W2-7(悬空 tool_calls 致 400)、卡住检测、max_iterations 护栏

开篇:循环该长什么样

agent 的核心是一个循环:模型决策 → 调工具 → 结果回灌 → 再决策,如此反复,直到模型觉得"我做完了"。这就是 ReAct 循环。

听起来简单。但循环设计是 agent 工程里最容易过度设计的地方。传统程序员的本能是"把流程写死"——先做 A,再做 B,如果 C 就跳到 D。一旦带着这种本能设计 agent 循环,你会写出一个庞大的状态机,每个状态一堆跳转条件,最后自己都维护不动。

pixiu 的循环设计原则只有一句:保持循环简单,复杂度下沉到工具和提示词。 这句话背后藏着一个认知转变——你要相信模型能自己做流程决策。

下面打开 core.py,看这个原则怎么落地的。

循环体:几乎没有 if-else 编排

pixiu 的核心循环在 core.py:_continue_agent_loop。我把它的骨架抽出来:

python
for i in range(self._max_iterations):
    remaining = self._max_iterations - i

    # 护栏1:迭代快耗尽,强制让模型给最终回复
    if remaining <= 2:
        return self.llm.chat_stream(messages, ...).content

    # 模型决策(带工具)
    response = self.llm.chat_stream(messages, tools=self._tools, ...)

    # 没有工具调用 → 模型说"我做完了" → 循环自然结束
    if not response.tool_calls:
        return response.content

    # 有工具调用 → 执行 → 结果回灌 → 继续循环
    messages.append(response.to_assistant_message())
    for tc in response.tool_calls:
        result = execute_tool(tc.name, tc.arguments)
        messages.append(tool_result_message(tc.id, result, ...))

你看到关键点了吗?这个循环里,没有任何 if-else 在替模型决定"该走哪条路"。 要不要调工具、调哪个、什么时候停——全是模型自己在循环里决定的。它不再请求工具调用(not response.tool_calls),循环就自然结束。

我要做的事情只有两件:给它工具(循环里传入的 self._tools)、给它护栏(下面讲)。我不编排它的流程。这就是"保持循环简单,复杂度下沉"的样子——你从"编排流程的人",变成了"设计工具和护栏的人"。

三层护栏

循环要简单,但不等于裸奔。模型可能在循环里干傻事——无限重试、卡在错误里出不来、迭代跑飞。pixiu 用三层护栏兜住这些。

护栏一:迭代上限 + 强制收尾。

max_iterations 给循环一个硬上限,防止无限循环。但光有上限还不够——如果在迭代中途硬截断,模型可能正在调工具,上下文里会留下"调了工具但没拿到结果"的悬空状态。所以 pixiu 的做法是:remaining <= 2 时,强制不带工具再调一次,逼模型基于已有信息给出最终回复:

python
if remaining <= 2:
    logger.info("迭代即将耗尽, 强制 LLM 输出最终回复")
    response = self.llm.chat_stream(messages, ...)  # 不传 tools
    return response.content or "分析过程中迭代次数不足..."

这个细节很重要——它保证了循环结束时,模型一定吐出了一段文字,而不是卡在工具调用的中间态。

护栏二:卡住检测(stuck detection)。

模型有时候会陷入死循环——反复调同一个工具、或者反复在同一种错误上失败。pixiu 在循环里埋了检测:

python
max_calls = get_config().advanced.max_same_tool_calls      # 连续相同调用上限
max_errors = get_config().advanced.max_same_tool_errors    # 连续失败上限
call_sig = f"{tc.name}:{tc.arguments[:60]}"
# 记录最近调用,统计同一签名的连续次数
if same_count >= max_calls:
    stuck_reason = f"连续 {same_count} 次相同调用 {tc.name}"
# 或连续失败
if error_count >= max_errors:
    stuck_reason = f"{tc.name} 连续 {error_count} 次执行失败"

一旦触发卡住,pixiu 不是硬停,而是注入一条提示让模型换路

python
messages.append({"role": "user", "content":
    f"检测到问题:{stuck_reason}。请换一种方式处理,或直接基于已有信息总结分析结果。"})

然后不带工具调一次,逼模型总结。这比硬截断聪明——它给了模型一个"自救"的机会。

护栏三:悬空 tool_calls 防护(W2-7)。

这是 pixiu 踩过最深的一个坑,标注 W2-7,值得单独讲。

深坑:W2-7,悬空 tool_calls 致 400

故事是这样的。有一天,画像相关的 eval 首跑,撞上一个诡异的错误:偶发 BadRequestError 400,提示"assistant 的 tool_calls 后面缺了对应的 tool 结果"。

一开始我以为是 max_iterations 截断在工具调用中途。查了半天,真因更刁钻:

模型有时候会在一个响应里同时返回多个 tool_call。如果其中一个 tool_call 触发了卡住检测,我原来的代码只给"当前这个 tool_call"补了结果,其余的 tool_call 悬空了——没有对应的结果消息。下次 LLM 调用时,OpenAI 协议要求每个 tool_call 都必须有配对的结果,悬空就报 400。

这是个协议一致性 bug。修复在 core.py 的卡住分支:

python
if stuck_reason:
    messages.append(tool_result_message(tc.id, result, is_error=is_error))
    # 关键修复:同一响应里其余 tool_call 也必须补结果,否则悬空致 400
    for rest_tc in response.tool_calls[idx + 1:]:
        messages.append(
            tool_result_message(rest_tc.id, "(已跳过:前序工具触发卡住检测)", is_error=True)
        )
    messages.append({"role": "user", "content": f"检测到问题:{stuck_reason}..."})
    ...

这个故事的教训是:agent 循环的边界情况,往往藏在协议细节里。 模型一次返回多个 tool_call、工具结果必须配对、流式响应的增量拼接……这些不是业务逻辑,是 LLM API 协议的硬约束。你的循环代码必须严格遵守这些协议,否则就会在某种偶发组合下崩成 400。

W2-7 现在被我当成"循环边界检查"的典型案例——每次改循环代码,都要想一遍:多 tool_call 的情况覆盖了吗?每个 tool_call 都有结果配对吗?

场景路由:不是所有流程都该交给模型自决

讲完"保持循环简单",得讲它的边界——不是所有流程都该让模型自己决定。

有些意图是高度确定的。用户说"跑一下布林带回测在 601088 上",这就是个确定性任务,你知道该调什么工具、参数怎么填。这种事如果还走自由 Agent Loop,让模型在循环里自己摸索,既慢又可能走偏。

pixiu 的做法是双路径,在 core.py:run() 入口处分流:

python
def run(self, user_message, ...):
    # Step 1: 场景路由识别意图
    scene = route(user_message)

    # Step 2: 场景明确且置信度够 → 走预制路径(Service 备数据 + 专用 prompt)
    if scene.scene_type != "general" and scene.confidence >= 0.3:
        result = self._run_scene_routed(scene, ...)
        if result:
            return result

    # Step 3: 场景不明确 → 走自由 Agent Loop
    return self._run_agent_loop(user_message, ...)

scene.pyroute() 用关键词匹配识别意图,置信度 ≥0.3 才走预制路径,否则降级到自由循环。预制路径里,Service 层把数据准备好、用专用 prompt 模板,首次调用甚至不带工具——因为数据都备好了,让模型直接叙事就行。

这是一个更精细的阶 4 认知:能确定的用编排(确定性路由),需要判断的才用自决循环。 别把所有东西都扔进 ReAct 循环让它自己悟——确定的流程,编排起来更快更稳。

循环全貌

把上面合起来,pixiu 的循环全貌是这样:

消息进来


场景路由(scene.py)── 确定且 conf≥0.3 ──→ 预制路径(Service+专用Prompt,带数据直接叙事)

  └─ 不确定 ──────────────────────────────→ 自由 Agent Loop

                                   ┌──────────┴──────────┐
                                   │  for i in max_iter:  │
                                   │    护栏1: remaining≤2 → 强制收尾 │
                                   │    模型决策(带工具)    │
                                   │    无工具调用 → 结束   │
                                   │    有工具 → 执行→回灌  │
                                   │    护栏2: 卡住检测 → 换路 │
                                   │    护栏3: 多tool_call补全│
                                   └─────────────────────┘

注意:这个循环体里没有"业务流程编排"。业务复杂度被下沉到了工具(第3、4章)和提示词里,循环本身只负责"转"和"兜底"。

这一章的工具:循环自检

  • [ ] 你的 agent 循环,能用一句话描述吗?描述不出来,大概率太复杂了。
  • [ ] 循环体里有没有大段 if-else 在替模型做流程决策?这些是不是本该让模型自己判断?
  • [ ] 有迭代上限吗?上限触发时是硬截断,还是强制让模型给最终回复?
  • [ ] 有卡住检测吗?连续相同调用 / 连续失败,有没有兜住?
  • [ ] 多 tool_call 的响应,每个 tool_call 都有结果配对吗?(W2-7 检查)
  • [ ] 哪些流程其实高度确定,却还在走自决循环?该不该用编排?

小结

循环设计的核心是克制:保持循环简单,把复杂度下沉。 让模型自己决定流程,你只负责设计好它手里的工具、和它身后的护栏。

pixiu 的循环体几乎是空的——没有业务编排,只有"模型决策→工具→回灌"的骨架和三层护栏。这种克制换来的是可维护性:业务变了,我改工具和 prompt,循环代码几乎不用动。

到这里,第二部分(设计)讲完了——算判分工、四阶认知、工具颗粒度、工具描述、上下文、循环。你已经知道怎么把一个 agent"做出来"了。下一部分,我们进入更难的话题:怎么验证它,怎么对付 LLM 的非确定性。

下一章

第 7 章 · 非确定性要用统计分层对付 —— 为什么 pixiu 的 eval 不统一跑 5 次。