跳到主要内容

RequirementAgent 主线:一次 run 走了什么

本章把 agent.run("...") 从入口追到出口,先看门面类做了什么,再深入运行器的循环。读完你能讲清「问答在框架里的端到端路径」。

1. 门面类做的三件事

RequirementAgent.__init__ 只是收集配置,真正干活的是它在 run 里造出来的 RequirementAgentRunner。门面层主要做三件事:

  • 把字符串 llm 解析成 ChatModel。 llm=ChatModel.from_name(llm) if isinstance(llm, str) else llm(agents/requirement/agent.py:134),所以你能直接写 llm="ollama:granite4:micro"
  • 把 role / instructions / notes 注入系统提示模板。agent.py:140-151,这些不是临时拼字符串,而是更新 self._templates.system 的默认值。
  • 默认开两道保险。 tool_call_checker=True(死循环检测)与 final_answer_as_tool=True(强制用工具交答案),都是构造参数。

run 方法本身被 @runnable_entry 装饰(agent.py:157)——这个装饰器把整个执行包进一个 RunContext(见 03 章),是全框架统一的执行入口约定。

2. run 的骨架

去掉细节,run 的主体就三步:造 runner、灌入历史 + 新消息、跑循环拿最终状态。

# 示意,浓缩自 agents/requirement/agent.py:175-212
runner = self.runner_cls(
llm=self._llm,
config=AgentExecutionConfig(max_iterations=..., total_max_retries=..., ...),
tools=self._tools,
requirements=self._requirements,
tool_call_cycle_checker=self._create_tool_call_checker(),
run_context=RunContext.get(), # 当前执行上下文
force_final_answer_as_tool=self._final_answer_as_tool,
templates=self._templates,
)
new_messages = self._process_input(input, backstory=..., expected_output=...)
await runner.add_messages(self.memory.messages) # 先灌历史
await runner.add_messages(new_messages) # 再灌本轮输入

final_state = await runner.run() # ← 跑循环,见第 3 节
# ...把 final_state.memory 写回 agent 自己的 memory...
return RequirementAgentOutput(output=[final_state.answer], output_structured=final_state.result, state=final_state)

重点看: runner 自己有一份独立 memory(UnconstrainedMemory),循环全程在它里面读写;循环结束后,门面才决定要不要把这份「含中间步骤」的记忆同步回 agent 的长期记忆(save_intermediate_steps 控制,agent.py:200-205)。

3. 运行器的主循环

核心在 RequirementAgentRunner.run()(agents/requirement/_runner.py:250-273)。循环条件极简:只要 state.answer 还是 None 就继续转

# 示意,浓缩自 agents/requirement/_runner.py:250-273
async def run(self):
await self._reasoner.update(self._requirements) # 初始化每个需求(校验工具存在等)
while self._state.answer is None:
self._increment_iteration() # iteration++,超过 max_iterations 报错
request = await self._create_request() # ① 规则引擎签发本步请求
await self._ctx.emitter.emit("start", ...) # 发 start 事件
response = await self._run(request) # ② 调模型 + 执行工具(见第 4 节)
await self._ctx.emitter.emit("success", ...)
return self._state

state.answer 怎么变成非 None?只有 FinalAnswerTool._run 被执行时,它会写 self._state.answer = AssistantMessage(...)(agents/requirement/utils/_tool.py:52-57)。也就是说,「停下来」这件事被建模成「模型调了一个叫 final_answer 的工具」——这是 BeeAI 的关键设计:终止不是特判,而是一次普通工具调用

4. 单步:_run 干的事

_runner.py 里的 _run(_runner.py:275-324)是一次迭代的真正内容。按顺序:

模型返回 response

├─ 没有 tool_call?
│ ├─ 本步允许停(can_stop)且有文本 → 把文本「抢救」成一次 final_answer 工具调用
│ │ (_create_final_answer_tool_call,_runner.py:174-191)
│ └─ 否则 → 记一次错误,要么重跑、要么追加「强制 final_answer」规则后重跑

├─ 有 tool_call → 逐个喂给死循环检测器
│ └─ 发现循环 → 追加一条「禁掉这个工具」的规则,重建请求并重跑

└─ 执行所有 tool_call(_invoke_tool_calls)
→ 每次调用产出一个 RequirementAgentRunStateStep(记进 state.steps)
→ 工具结果包成 ToolMessage,连同模型消息一起写回 state.memory

两个值得记住的细节:

  • 文本答案的「抢救」。 弱模型常常不调 final_answer 工具,而是直接吐一段文本。_run 会在允许停的前提下,把这段文本(或其中第一个 {...} JSON 片段)硬转成一次 final_answer 工具调用(_runner.py:281-306)。这样「模型不会用工具」也不至于卡死。
  • state.steps 是审计轨。 每次工具调用都追加一个 RequirementAgentRunStateStep(含 iteration / input / output / tool / error,_runner.py:210-219)。规则引擎下一步就靠读这份 steps 来判断「某工具调过几次、上一步是谁」——这是规则能「随状态变化」的数据基础(见 02 章)。

5. 系统提示是怎么拼出来的

每一步的系统提示不是固定的——它会把「本步允许哪些工具、为什么不允许某工具」写进去。组装在 _create_system_message(agents/requirement/utils/_llm.py:171-193):对每个非隐藏工具,渲染出 Name / Description / Allowed: true|false / Reason: ...

模板本体在 agents/requirement/prompts.py:42-95(RequirementAgentSystemPrompt),其中工具区段长这样:

# 示意,取自 prompts.py 模板片段(Mustache 语法)
{{#tools}}
Name: {{name}}
Description: {{description}}
Allowed: {{allowed}}{{#reason}}
Reason: {{&.}}{{/reason}}
{{/tools}}

注意:框架同时用两种手段约束模型——一是把 Allowed: false 写进 prompt(软约束,给模型解释),二是在调模型时设 tool_choice 并只传 allowed_tools(硬约束,API 层面限制)。后者才是真正强制的那一层(见第 6 节)。

6. 调模型这一步:tool_choice 与跨模型降级

_prepare_llm_request(_runner.py:136-172)把系统提示 + 记忆组成 messages,并设好 ChatModelOptions:

  • tools=request.allowed_tools —— 只把允许的工具给模型;
  • tool_choice=request.tool_choice —— 可能是 "auto" / "required" / 或某个具体工具(强制);
  • fallback_tool=request.final_answer if request.can_stop else None —— 兜底用的工具。

这里有 BeeAI 抹平模型差异的最后一环:不是所有模型 / 提供商都支持「强制调某个工具」ChatModel._force_tool_call_via_response_format(backend/chat.py:796-819)会判断:如果模型不支持当前 tool_choice,就改用 structured-output(response_format)来逼出工具调用。换句话说,「强制」这件事在能力强的模型上走原生 tool_choice,在能力弱的模型上自动降级成「让你按这个 JSON schema 输出」——对上层 RequirementAgent 完全透明。

这就是为什么同一个带规则的 agent 能在差异巨大的模型上都保持行为一致:规则层只管「该强制哪个工具」,降级层管「在这个模型上怎么把强制做出来」。


下一章进入真正的核心:规则引擎怎么把一堆需求算成「本步允许哪些工具」。→ 02-rule-engine.md