跳转到主要内容
这一课是你第一次主动弯折默认 loop。 你依旧在构建一个普通的 QitOS agent,但会新引入三件事:
  • 放进 state 的 planning artifact
  • 与执行 prompt 分离的 planner prompt
  • 一个只处理 planning 边界的 decide() override
关键点是:你仍然没有引入第二个 runtime。

相比第 1 课的变化

设计分支第 1 课第 2 课
控制流每步都走默认 LLM 路径只有 planning 边界由 decide() 拦截
Prompting单个 ReAct system promptplanning prompt + execution prompt
Statescratchpad + task fields增加 plan_stepscursor
ParserReActTextParser执行阶段仍是 ReActTextParser
Tools紧凑 coding tools保持不变
Memory / History除 state 外没有额外层仍不引入 memory 或 compaction
最后一行尤其重要:这一课增加的是 planning,不是 context complexity。

双 prompt 架构

这一课会显式使用两份 prompt 契约。

Planner prompt

You are a planning module.
Break the task into 3-7 atomic executable steps.

Constraints:
- Each step must be actionable and verifiable.
- Prefer tool-executable operations over vague reasoning.
- No prose outside the numbered list.
在代码中通常命名为 PLAN_DRAFT_PROMPT

Executor prompt

You are the execution module for a Plan-Act agent.

You will receive the global task and one current plan step.
Execute only the current step. Do not jump ahead.

Output contract (strict):
Thought: <one sentence>
Action: <tool_name>(arg=value, ...)
or
Final Answer: <step result>
在代码中通常命名为 PLAN_EXEC_SYSTEM_PROMPT 这一课想让你建立的设计意识是:
  • planning 和 acting 可以使用不同 prompt
  • 但它们仍然流经同一个 AgentModule + Engine runtime

这一课里的 parser 逻辑

planner path 使用 ReActTextParser 它通常是这样工作的:
  • _plan() 渲染 PLAN_DRAFT_PROMPT
  • NumberedPlanBuilder 调用同一个 LLM harness
  • builder 把编号列表解析成 list[str]
而执行路径 仍然使用 ReActTextParser 这已经体现了一个非常关键的 QitOS 设计点: 同一个 agent 的不同阶段可以使用不同 parsing contracts,只要控制边界是显式的。

model harness 依旧保持“无聊”

和第 1 课一样,这一课仍然使用:
OpenAICompatibleModel(...)
刻意保持同一 harness 的原因是:
  • 这样你能单独观察 planning 带来的变化
  • prompt 与 parser 的变化更容易解释
  • 课程一次只引入一个真正的新变量
1

在 state 中加入计划与 cursor

新 state 只增加执行真正需要的字段:
@dataclass
class PlanActState(StateSchema):
    plan_steps: list[str] = field(default_factory=list)
    cursor: int = 0
    target_file: str = "buggy_module.py"
    test_command: str = TEST_COMMAND
    scratchpad: list[str] = field(default_factory=list)
这是课程里第一次把隐藏 reasoning artifact 显式写进 state。为什么这么做:
  • trace 可以直接展示它
  • prepare() 可以显式暴露它
  • reduce() 可以推进它
  • 以后若需要,也可以主动重写它
2

使用专用的 plan builder

planner 通常在构造时初始化:
self.plan_builder = NumberedPlanBuilder()
调用方式类似:
prompt = render_prompt(
    PLAN_DRAFT_PROMPT,
    {
        "task": (
            f"{state.task}\n"
            f"Target file: {state.target_file}\n"
            f"Last step must run: {state.test_command}"
        ),
    },
)
plan = self.plan_builder.build(self.llm, prompt)
正确的 QitOS 做法是:让 planning 变成有名字的 artifact,并且有自己清晰的 parser,而不是混进主 scratchpad 的一段自由文本。
3

把 decide 只用作 planning gate

这一课的控制逻辑通常非常小:
def decide(self, state: PlanActState, observation: dict[str, Any]):
    if not state.plan_steps or state.cursor >= len(state.plan_steps):
        if not self._plan(state):
            return Decision.final("Failed to build a valid plan.")
        return Decision.wait("plan_ready")
    return None
其中最重要的是最后一句:return None一旦 plan 已经存在,Engine 会重新回到默认模型路径:prompt -> ReActTextParser -> Decision -> tool execution所以第 2 课不是替换 runtime,而是在 runtime 上加了一道显式控制边界。
4

把执行 prompt 与 parser 明确绑定

执行阶段仍是:
super().__init__(
    tool_registry=registry,
    llm=llm,
    model_parser=ReActTextParser(),
)
同时:
def build_system_prompt(self, state: PlanActState) -> str | None:
    return render_prompt(
        PLAN_EXEC_SYSTEM_PROMPT,
        {
            "current_step": self._current_step_text(state),
            "tool_schema": self.tool_registry.get_tool_descriptions(),
        },
    )
因此 planning 阶段和 execution 阶段是清晰分离的:
  • planning:编号列表 builder
  • execution:ReAct 文本协议
5

让 prepare 显式展示 plan

prepare() 现在不再只渲染任务,而是同时渲染当前计划进度:
def prepare(self, state: PlanActState) -> str:
    lines = [
        f"Task: {state.task}",
        f"Plan cursor: {state.cursor}/{len(state.plan_steps)}",
        f"Current plan step: {self._current_step_text(state)}",
        f"Step: {state.current_step}/{state.max_steps}",
    ]
agent 的工作记忆也因此发生了变化。模型每一步看到的不再是整个问题,而是:
  • 一个任务
  • 一份显式计划
  • 当前要执行的单个 plan item
6

在 reduce 里推进计划进度

计划推进仍然是普通 state logic:
if isinstance(first, dict) and first.get("status") == "success":
    state.cursor += 1
if isinstance(first, dict) and int(first.get("returncode", 1)) == 0:
    state.final_result = "Verification passed."
    state.cursor = len(state.plan_steps)
关键不在于这两个条件本身,而在于它们出现的位置:reduce() 就是你定义“什么算 plan 完成”的地方。
7

故意保持 memory 与 history 简单

第 2 课依旧不引入:
  • memory adapter
  • HistoryPolicy 调优
  • CompactHistory
因为此时 plan 本身已经是一种压缩任务结构的方式。如果同时引入 context compaction,你就很难判断行为变化到底来自 planning 还是来自上下文管理。
8

运行并在 qita 中检查 planning 边界

运行:
python examples/patterns/planact.py
查看:
qita board --logdir runs
在 trace 中重点看:
  • 哪一步出现了 Decision.wait("plan_ready")
  • plan_steps 是何时进入 state 的
  • 后续执行是否仍然沿着同一个 ReAct parser 路径前进

为什么 PlanAct 仍然是同一个内核

很多人一旦想到 planning,就会下意识引入:
  • 单独的 planner service
  • 框架之外的 planner-executor loop
  • 第二个 agent runtime
QitOS 在这一课想强调的恰恰相反:
  • planner 只是另一种受控模型调用
  • plan 只是另一种 state artifact
  • 执行仍然走普通 Engine path
这是 QitOS 很深的一条设计原则。

完整示例

完整可运行代码位于:

第 3 课会新增什么

第 3 课依旧不换内核,但 agent 会真正变成长时运行工作流。 这意味着你将第一次认真面对:
  • preset toolsets,而不是手工 wiring
  • workflow-oriented system prompt
  • 显式 history control
  • context compaction 与 memory 何时成为必要设计问题

下一课:Claude Code 风格 agent

从 pattern 设计走向长时运行的 workspace agent

相关指南:Memory 与 History

在进入长时运行前,先回顾 state、history、compaction 与 memory 的边界