跳转到主要内容
这是整条课程里第一个完整 agent。 它刻意保持小巧,但并不“玩具化”。这一课里你会看到一个真正可运行的 coding agent,它具备:
  • 强类型 state
  • 真实 LLM harness
  • 真实 system prompt
  • 真实 parser
  • 真实 tools
  • 真实 reduce() 循环
  • 真实 qita traces
你会结合 examples/patterns/react.py 来学习,但这节课本身会把设计解释清楚,不要求你先反向阅读源码。

你要构建什么

任务非常小:
  • 打开 buggy_module.py
  • 修复 add(a, b),让它返回 a + b
  • 运行验证命令
正因为任务小,它特别适合拿来观察完整 QitOS 内核,而不会被额外 orchestration 噪音掩盖。

本课的设计选择

设计分支本课选择为什么这是最好的起点
任务形态单文件 bug fix + 单个验证命令易验证、易追踪
Statescratchpadtarget_filetest_command足够影响下一步决策,但不引入冗余状态
Model harness返回文本的 OpenAICompatibleModel简单、可迁移、provider-agnostic
Prompt 契约REACT_SYSTEM_PROMPT明确的一次一工具文本协议
ParserReActTextParserThought: / Action: 完整对应
Tools手工 ToolRegistry + 紧凑 CodingToolSet先学清楚工具表面,再学 presets
Memory不使用run 很短,没有引入单独 memory 的必要
History使用 Engine 默认行为在理解内核之前不引入额外 context control
Traceabilityqita board从第一课就学会检查 kernel

为什么从文本 ReAct harness 开始

本课使用:
OpenAICompatibleModel(...)
以及:
model_parser=ReActTextParser()
这样你能看到最透明的一条路径: messages -> text model output -> ReAct parser -> Decision -> tool execution 我们不从 native tool calling、XML、JSON 或 model-specific harness 开始,因为那些选择会在你还没理解核心 loop 之前,过早引入协议耦合。

system prompt 是协议,不是装饰

本课使用的 ReAct prompt 大致是:
You are a reliable ReAct agent.

Rules:
- Use at most one tool call per response.
- Never invent tool names or arguments.
- If a tool result is enough to conclude, output final answer directly.

Output contract (strict):
Thought: <one concise reasoning sentence>
Action: <tool_name>(arg=value, ...)
or
Final Answer: <final answer only>
在代码里通常表现为:
def build_system_prompt(self, state: ReactState) -> str | None:
    return render_prompt(
        REACT_SYSTEM_PROMPT,
        {"tool_schema": self.tool_registry.get_tool_descriptions()},
    )
这一点很关键。ReActTextParser 不是在“猜”模型输出,它是在消费这份明确约定好的协议。 本课最重要的第一条经验就是:
  • prompt 格式与 parser 选择,本质上是一项联合设计决策
  • 改了其中一个,通常就要连另一个一起改

本课的 model harness

示例里的模型构造通常长这样:
def build_model() -> OpenAICompatibleModel:
    return OpenAICompatibleModel(
        model=MODEL_NAME,
        api_key=api_key,
        base_url=MODEL_BASE_URL,
        temperature=0.2,
        max_tokens=2048,
    )
为什么这里选这个 harness:
  • 它适用于 OpenAI-compatible endpoints
  • 模型返回纯文本,便于观察
  • 与基于 prompt 注入 tool schema 的路径天然兼容
  • 让课程可以在不同 provider 与本地网关间迁移
这节课你不是在选择“最强模型”,而是在选择“最能暴露内核结构的 harness”。
1

围绕下一步决策来设计 state

本课的 state 刻意很小:
@dataclass
class ReactState(StateSchema):
    scratchpad: list[str] = field(default_factory=list)
    target_file: str = "buggy_module.py"
    test_command: str = (
        'python -c "import buggy_module; assert buggy_module.add(20, 22) == 42"'
    )
为什么是这三个字段:
  • scratchpad:保存压缩后的最近轨迹,供下一步决策使用
  • target_file:让 agent 始终围绕单一工件行动
  • test_command:把“任务完成”变成一个可执行成功条件
这是第一条 QitOS 习惯:只给 state 加那些会改变未来决策的字段。
2

暴露尽量小的 tool surface

示例使用手工 registry,让你能精确看到暴露给模型的能力:
registry = ToolRegistry()
registry.include(
    CodingToolSet(
        workspace_root=workspace_root,
        include_notebook=False,
        enable_lsp=False,
        enable_tasks=False,
        enable_web=False,
        expose_modern_names=False,
    )
)
CodingToolSet 本身是 bundle,但它的表面仍是你主动裁剪出来的。对第 1 课来说,最合适的工具集合只需要覆盖:
  • 查看文件
  • 编辑文件
  • 运行验证命令
在任务没有提出需求之前,不要暴露更大的工具面。
3

把 prompt 与 parser 明确绑在一起

agent 构造函数里通常会这样写:
super().__init__(
    tool_registry=registry,
    llm=llm,
    model_parser=ReActTextParser(),
)
要把这句话读成一个整体:“这个 agent 要求模型输出 ReAct 文本,并由 ReAct parser 负责解析。”在 QitOS 里,这个绑定本身就是 harness 的一部分。
4

prepare 只组织下一步真正需要的上下文

prepare() 负责把 state 变成 prompt-ready 文本:
def prepare(self, state: ReactState) -> str:
    lines = [
        f"Task: {state.task}",
        f"Target file: {state.target_file}",
        f"Verification command: {state.test_command}",
        f"Step: {state.current_step}/{state.max_steps}",
    ]
    if state.scratchpad:
        lines.append("Recent trajectory:")
        lines.extend(state.scratchpad[-8:])
    return "\n".join(lines)
第二条核心习惯就是:prepare() 不是 state dump,而是 prompt 视图。
5

让 reduce 决定 agent 记住什么

ReAct 的“学习”发生在 reduce() 里:
def reduce(
    self,
    state: ReactState,
    observation: dict[str, Any],
    decision: Decision[Action],
) -> ReactState:
    action_results = (
        observation.get("action_results", [])
        if isinstance(observation, dict)
        else []
    )
    if decision.rationale:
        state.scratchpad.append(f"Thought: {decision.rationale}")
    if decision.actions:
        state.scratchpad.append(f"Action: {format_action(decision.actions[0])}")
    if action_results:
        first = action_results[0]
        state.scratchpad.append(f"Observation: {first}")
        if isinstance(first, dict) and int(first.get("returncode", 1)) == 0:
            state.final_result = "Patch applied and verification passed."
    state.scratchpad = state.scratchpad[-30:]
    return state
这一个函数里藏着三条原则:
  • 不是每条 observation 都值得回灌进未来上下文
  • state 是压缩工作记忆,而不是全量日志
  • final_result 是成功的显式信号
6

理解本课还没有引入什么

第 1 课刻意不使用:
  • decide() overrides
  • explicit planning
  • memory adapters
  • custom history
  • context compaction
  • model-specific protocol overrides
不是因为 QitOS 没有这些能力,而是因为在你看清默认主路径之前,不应该过早弯折它。
7

运行示例,并用 qita 检查内核

运行:
python examples/patterns/react.py
然后查看:
qita board --logdir runs
qita 中重点检查:
  • 发送给模型的 prompt 是否符合预期
  • parser 是否干净地产生了 ThoughtAction
  • 工具输出是否让成功条件变得明确
  • final_result 是否在真正成功的第一时间被设置

为什么这一课不引入 memory 或 compaction

本课最正确的 memory 选择就是:不用 原因很简单:
  • run 很短
  • scratchpad 已足够容纳有效上下文
  • 过早引入 retrieval 或 compaction 会模糊架构边界
在 QitOS 中,memory 不是“高级 agent 的标配”,它是为真实长时运行问题准备的答案。

完整示例

完整可运行代码位于:

第 2 课会引入什么

第 2 课会保持相同的 model harness 和执行 parser,但引入一个新的设计点: planning 应该变成显式 state 与显式控制边界,而不是藏在更长的 thought 里。

下一课:PlanAct

加入 planner、cursor 与 decide() gate,但不更换核心 runtime

相关参考:Kit

回看本课使用的 ReActTextParser、prompt templates 与 coding tool surface