主题
第 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.py 的 route() 用关键词匹配识别意图,置信度 ≥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 次。