经典范式手写

经典范式手写

第 07 章我们写出了最小 Agent Loop:while (iterations < maxIterations) 里跑 think → act → observe,模型要工具就执行回填,不要就把文本当答案。那一刻你可能有种「我已经会写 Agent 了」的错觉:循环有了,但循环的行为不可预期

同一个 50 行的 loop,塞给同一个模型,你能得到完全不同的走法:它可能查完天气就收工、完全忘了你还让它看景点;可能在同一个失败结果上反复重试同一把工具;可能把一段明显不合格的文案直接交付给你。循环保证了「它会一直跑」,但没保证「它跑得像样」。这一章给「像样」补上三种业界沉淀下来的控制结构模板:范式(paradigm),ReAct、Plan-and-Execute、Reflection。它们都是第 07 章那个 loop 的变体,不需要任何新依赖,只改控制流。

本章目标

读完本章并做完配套练习后,你应该能够:

开场反例:裸 loop 的行为不可预期

回到第 07 章的天气助手,任务升级成:

苏州周末适合户外活动吗?帮我查一下苏州周末的天气,再看两个值得去的景点,最后给一份简短建议。

把任务丢进裸 loop,会发生什么?取决于模型当时的发挥,它可能:

  1. 查完天气就收工:「适合,记得防晒」,景点的事完全忘了;
  2. 查到一半开始纠结:景点 A 和景点 B 先查哪个?要不要再查第三遍天气?
  3. 干脆不查工具,凭知识库编一段「拙政园周末晴」(幻觉)。

你无法预测它会走哪条路,因为**「下一步做什么」完全交给每一轮模型的临场发挥**。三个问题悬而未决:

业界沉淀出的三种经典范式,恰好是这三个问题的三个答案。它们共享同一个内核,第 07 章的 loop,只是控制流模板不同:

flowchart LR
  base["第 07 章最小 loop<br/>think → act → observe"] --> react["ReAct<br/>把思考轨迹显式化"]
  base --> pne["Plan-and-Execute<br/>先计划(数据)再执行"]
  base --> refl["Reflection<br/>外层套 draft → judge → revise"]

datawhalechina/hello-agents 的第四章用一整章逐一实现过这三者(第四章 智能体经典范式构建),我们这一章用 TypeScript 把它们压进三个函数,下面逐个来。

范式一:ReAct——边想边做

动机:思考与行动分开都会坏

在 ReAct 之前,让模型解决多步任务有两条路,各坏一半:

ReAct(Yao et al., 2022,arXiv:2210.03629)把两者交替编排:先思考再行动,行动结果成为下一次思考的输入。这个「思考-行动-观察」的循环,正是第 07 章我们手写的那个 loop,ReAct 就是加了思考轨迹的 Agent Loop

flowchart TD
  start(["任务进入 messages"]) --> think["Thought · 模型分析现状与下一步<br/>输出文本(思考)+ 结构化 tool_calls"]
  think --> hasTool{"要调用工具?"}
  hasTool -- "是" --> act["Action · harness 执行工具<br/>模型只决定,不执行"]
  act --> obs["Observation · 结果作为 tool 消息回填<br/>成为下一轮思考的输入"]
  obs --> think
  hasTool -- "否" --> final["思考轨迹 + 文本即最终答案"]

最小实现:loop + 思考轨迹

第 07 章的 runAgent 只差一件事:把每一轮模型的文本记下来,这就是 ReAct 的「Thought 轨迹」,它让每一步行动的原因可以被看见、被调试。其余一字不改:

export async function runReact(
  initialMessages: Message[],
  opts: { callLlm: CallLlm; tools: Tool[]; maxIterations?: number },
): Promise<{ messages: Message[]; finalText: string; iterations: number; thoughts: string[] }> {
  const { callLlm, tools, maxIterations = 5 } = opts;
  const messages = [...initialMessages];
  const thoughts: string[] = []; // 思考轨迹:一次模型调用记一条
  let iterations = 0;

  while (iterations < maxIterations) {
    const response = await callLlm(messages, tools);
    thoughts.push(response.content); // 这一轮模型「想」了什么

    const assistant: Message = { role: 'assistant', content: response.content };
    if (response.toolCalls.length > 0) assistant.tool_calls = response.toolCalls;
    messages.push(assistant);

    if (response.toolCalls.length === 0) {
      return { messages, finalText: response.content, iterations: iterations + 1, thoughts };
    }

    for (const call of response.toolCalls) {
      const tool = tools.find((t) => t.name === call.name);
      if (!tool) throw new Error(`Unknown tool: ${call.name}`);
      const output = await tool.execute(call.arguments);
      messages.push({ role: 'tool', tool_call_id: call.id, content: output });
    }

    iterations += 1;
  }

  const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
  return { messages, finalText: lastAssistant?.content ?? '', iterations, thoughts };
}

注意 thoughts 里存的是模型输出的原始文本,不是我们臆测的「意图」。有了它,你可以事后回答「它为什么调了这把工具」「它在哪一步跑偏了」,这是调试一切 Agent 行为的第一手材料,也是第 13 章评估里「失败模式分类」的输入。

适用场景

ReAct 是默认范式,没有特殊理由就用它:

一句话:ReAct 买的是灵活性,每步都被最新观察锚定,代价是每步一次模型往返。

范式二:Plan-and-Execute——先谋后动

动机:ReAct 的「走一步看一步」在复杂任务上会跑偏

ReAct 的弱点从它的优点长出来:决策粒度是一步。任务步骤一多,模型可能在中途迷失:查了 A 忘了 B、在某个分支上反复打转。Plan-and-Solve(Wang et al., 2023,arXiv:2305.04091)给出的方案朴素而有效:先整体规划,再执行。规划模型先生成完整步骤计划,执行模型再逐步完成,「三思而后行」:

flowchart TD
  start(["任务"]) --> phase1
  subgraph phase1["规划阶段 · 1 次模型调用"]
    p1["模型产出计划<br/>{ steps: [...] }——计划是数据,不是散文"]
  end
  phase1 --> phase2
  subgraph phase2["执行阶段 · harness 机械执行"]
    e1["step1 查天气 → 记录结果"]
    e2["step2 查景点 → 记录结果"]
    e3["... 其余工具步骤,按序执行"]
  end
  phase2 --> phase3
  subgraph phase3["综合阶段 · 1 次模型调用"]
    s1["模型收到任务 + 全部观察<br/>产出最终答案"]
  end
  phase3 --> final(["finalText"])

两个阶段的分工值得看清:规划是模型的事,执行是 harness 的事。规划阶段产出的是数据(一个 steps 数组),不是散文,harness 才能直接遍历执行、校验、甚至展示给人看。

最小实现:计划当数据 + 机械执行 + 一次综合

export async function runPlanAndExecute(
  task: string,
  opts: { callLlm: CallLlm; tools: Tool[] },
): Promise<{ plan: PlanStep[]; results: Record<string, string>; finalText: string; iterations: number }> {
  const { callLlm, tools } = opts;

  // 阶段一:规划。模型第一轮的 content 是 JSON 序列化的计划。
  const planResponse = await callLlm([{ role: 'user', content: task }], tools);
  const plan = parsePlan(planResponse.content);

  // 阶段二:执行。除最后一步外,每步都必须带 tool,harness 机械执行。
  const results: Record<string, string> = {};
  for (let i = 0; i < plan.steps.length - 1; i++) {
    const step = plan.steps[i];
    if (!step.tool) throw new Error(`step ${step.id} has no tool but is not the final synthesis step`);
    const tool = tools.find((t) => t.name === step.tool);
    if (!tool) throw new Error(`Unknown tool: ${step.tool}`);
    results[step.id] = await tool.execute(step.args ?? {});
  }

  // 阶段三:综合。最后一次调用收到任务 + 全部观察,产出最终答案。
  const observations = plan.steps
    .filter((s) => results[s.id] !== undefined)
    .map((s) => `- ${s.description}: ${results[s.id]}`)
    .join('\n');
  const solveResponse = await callLlm(
    [
      { role: 'user', content: task },
      { role: 'user', content: `已收集到的信息:\n${observations}` },
    ],
    [],
  );

  return { plan: plan.steps, results, finalText: solveResponse.content, iterations: 2 };
}

对照 ReAct 看取舍:同一个「查天气 → 查景点 → 出建议」任务,ReAct 要 3 次模型调用(每步决策一次),Plan-and-Execute 只要 2 次(规划 + 综合),中间的工具步骤由 harness 机械执行、零模型调用。省下来的调用是拿灵活性换的:计划一旦生成,执行阶段不会再根据观察调整:计划之外的情况一概不管。

learn-claude-code 的 s05(TodoWrite)在生产里的形态就是它:先列任务清单、逐项打勾执行,作者实测「有计划 vs 无计划的完成率翻倍」(s05 TodoWrite)。pguso 的 Lesson 08 则从另一个角度印证:计划应该当数据存、当数据展示,而不是埋在思考里Lesson 08: Planning)。

适用场景

一句话:Plan-and-Execute 买的是效率与可控性,模型调用少、计划可见可批;代价是环境若在计划后变化,它会执行一份过期计划。

范式三:Reflection——写完回头改

动机:一次生成的输出,质量没人把关

ReAct 和 Plan-and-Execute 都在解决「怎么做事」,Reflection 解决「做得怎么样」。生成式任务的输出是概率的:模型第一版文案可能漏了关键信息、代码可能藏着边界 bug,而且它自己没意识到。Reflexion(Shinn et al., 2023,arXiv:2303.11366)的思想是给 Agent 加一条自我批判回路:生成 → 评审 → 不通过就带着评审意见修订,直到通过或达到轮数上限。注意它的名字叫 Reflexion 而非 Reflection。论文强调这是语言形式的强化学习:不改权重,只把「哪里错了、怎么改」写进上下文,下一轮自然改好:

flowchart TD
  gen["生成一版草稿"] --> judge{"评审通过?"}
  judge -- "是" --> done["草稿即最终答案"]
  judge -- "否" --> fb["草稿 + 评审意见追加进消息历史<br/>要求模型修订"]
  fb --> gen

最小实现:draft → judge → revise 外环

关键设计:评审(judge)是注入的。它可以是规则检查器(「必须包含防晒建议」「长度 ≥ 40 字」),也可以是一次评审 LLM 调用,练习里注入脚本化的确定性评审,演示里走模型通道。生成器与评审解耦,Reflection 环才能离线可测:

export async function runReflect(
  task: string,
  opts: { callLlm: CallLlm; judge: (candidate: string) => JudgeVerdict | Promise<JudgeVerdict>; maxRounds?: number },
): Promise<{ finalText: string; drafts: string[]; feedbacks: string[]; rounds: number }> {
  const { callLlm, judge, maxRounds = 3 } = opts;
  const messages: Message[] = [{ role: 'user', content: task }];
  const drafts: string[] = [];
  const feedbacks: string[] = [];
  let rounds = 0;

  while (rounds < maxRounds) {
    const response = await callLlm(messages, []);
    const draft = response.content;
    drafts.push(draft);

    const verdict = await judge(draft);
    rounds += 1;
    if (verdict.passed) {
      return { finalText: draft, drafts, feedbacks, rounds };
    }

    feedbacks.push(verdict.feedback);
    // 把草稿与评审意见追加进历史:下一轮修订能看到「自己上一版为什么被否」
    messages.push({ role: 'assistant', content: draft });
    messages.push({ role: 'user', content: `评审意见:${verdict.feedback}。请根据意见修改,重新输出。` });
  }

  // 保险丝:评审一直不过,用最后一版草稿兜底,绝不让用户拿到空字符串
  return { finalText: drafts[drafts.length - 1] ?? '', drafts, feedbacks, rounds };
}

注意消息历史的追加顺序:task → assistant(草稿) → user(评审意见)。这与第 07 章 loop 的「只追加」精神一致:修订轮模型看到的是完整过程,而不是孤立的一句话。rounds 是评审次数,drafts 记录每一版,如果修订越改越差,你能从 drafts 里挑出最好的一版(best-of-n 是 Reflection 的常见加固,见下文坑四)。

适用场景

一句话:Reflection 买的是质量,输出要过评审关;代价是生成 + 评审的调用都算成本,且评审质量依赖评审者(可能和生成者是同一个模型)。

三范式对照与选择

维度ReActPlan-and-ExecuteReflection
决策时机每一步开头一次(计划)生成后(评审)
计划隐式(散在每步思考里)显式(steps 数据)
模型调用数每步 1 次,最多少(规划 + 综合)生成 + 评审,翻倍
适应性高(每步随观察调整)低(计划提交后不再调整)中(对自己的评审意见调整)
典型场景实时信息、探索式任务可预见多步任务、计划要给人看文案/代码等质量敏感生成
主要失败模式思考与行动脱节、死循环计划过期、计划当散文评审含糊、同源盲区、修订倒退

选型不是非此即彼,更常见的是组合:ReAct 里嵌一步「先给个计划」(混合范式,下一节讲);Plan-and-Execute 的规划阶段可以用 Reflection 把计划评审一遍;Reflection 的评审者可以换成另一个模型。但默认从 ReAct 起步pguso 的课程顺序也是「先把 loop 跑稳,再谈规划与反思」(Lesson 08: Planning)。只有任务明确暴露了 ReAct 的问题(跑偏、太贵、质量差),才值得上更重的结构。

native tool calling 时代,哪些范式「免费」了

这一节回答 PLAN 里 TP11 的最后一个问题:协议把哪些机制内化了,导致你什么都不用写?

范式免费吗为什么
ReAct✅ 基本免费原生 tool calling 把 Thought/Action/Observation 结构化为协议:思考就是每轮的 content,行动就是结构化 tool_calls,观察就是 tool 消息回填。第 07 章那个 loop 就是 ReAct。对比 ReAct 文本协议时代(2022):你不仅要写正则解析 Action: {...} 行,还要用 stop=["Observation:"] 截断生成防模型幻觉 Observation。这些在原生路线下全部消失(第 07 章对比表讲过)。
Reflection🔶 半免费代码量确实很小:多一个评审 prompt 或注入一个 judge,十行以内。但成本不免费:每轮评审都是一次模型调用,质量收益要覆盖翻倍的调用量;且评审质量依赖模型,同一个模型的自我评审有同源盲区。协议没有把「自我批判」内化给你。
Plan-and-Execute❌ 不免费计划结构是 harness 的控制流设计,协议不提供任何「计划」原语。规划 → 机械执行 → 综合的三段式必须你自己写;计划当数据存、给人看、甚至交给人批,全是应用层的事。reasoning 模型在内部「想得更多」不等于产出可展示、可执行、可审批的计划数据。

判断「免费与否」有个好用标准:这个机制的载体是协议还是 harness。协议把机制内化了(tool calling 之于 ReAct),你就免费;机制要靠控制流结构实现(计划之于 PnE、评审回路之于 Reflection),你就得自己写。顺便说一句,ReAct 的「免费」有一个隐含前提:模型厂商要支持原生 tool calling(近两年主流模型均已支持);在只有文本模型的场景(本地小模型),ReAct 文本协议仍是那条后备路线,第 07 章的对比表就是为这种时刻准备的。

常见坑与失败模式

坑一(ReAct):思考轨迹不落地。 模型 Thought 说「先查天气」,Action 却去调了搜索,思考与行动脱节。原因多在 prompt:工具描述不清、思考格式约束太松。把 thoughts 打出来看一遍,通常立刻能定位。

坑二(ReAct):死循环。 和 ch07 一样,模型每轮都要工具时 maxIterations 是唯一的救星。反思一下:只有 ReAct 会死循环吗? 不——Plan-and-Execute 的规划阶段若模型反复产出非法计划、Reflection 的 judge 若永远不过,一样会转圈。每个循环变体都要有自己的保险丝maxIterations / maxRounds)。

坑三(Plan-and-Execute):计划过期。 计划提交后环境变了(要查的城市下雨了、数据库 schema 换了),执行阶段仍按旧计划跑。最小实现不做重规划;生产里常见的加固是混合范式:在计划里显式安排一个「复查」步骤,或执行若干步后带着新观察重新规划一次,这本质上又回到 ReAct 的灵活性,只是把「重规划」变成计划中的一等公民。

坑四(Plan-and-Execute):计划当散文不当数据。 模型把计划写成自然语言段落,harness 只能靠正则和 indexOf 去捞步骤,第 05 章的教训在这里重演。让模型输出 { steps: [...] } JSON,解析、校验、展示、审批全都顺了;解析失败就抛错重试,别猜。

坑五(Reflection):评审标准含糊。 judge 的标准越主观(「写得不错」「有点干」),评审结果越随机,修订越像碰运气。把评审做成可判的检查点:「必须包含具体温度」「代码必须能通过这组单元测试」「输出 ≤ 100 字」。Hello-Agents 第四章在实现反思时也提醒:评审意见要具体到能指导修改(第四章 智能体经典范式构建)。

坑六(Reflection):同源盲区与修订倒退。 同一个模型既当生成者又当评审者,它看不出自己的系统性错误(「中文就这个风格」),这叫同源盲区:对策是换模型/换视角当评审。而修订可能越改越差:保留历史最佳版本drafts 里挑评分最高的,best-of-n)是廉价且有效的加固。

坑七(共通):把范式当银弹。 「加了 Reflection 就高级」「不上 Planning 就不专业」,范式是控制结构,不是信仰。判断该不该用哪个范式,唯一可靠的手段是第 13 章要讲的评估:定义任务集、跑出失败模式、用数据决定。这也是本章练习之外最该带走的心态。

动手练习

配套练习要求你在 starter/src/paradigms.ts 里实现三个函数,把第 07 章的 loop 改造成三种范式,并通过三个 stage 的测试:

练习全部用注入式的脚本化 mock 模型判题,无需 API key、结果确定可复现。

小结

下一章(ch13)做评估入门:任务集、grader、失败模式分类,你会发现第 13 章的三类失败模式(planning / tool / efficiency)恰好对应本章三种范式的软肋:计划跑偏、工具用错、调用过贵。评估是把「我觉得这个范式好」变成「数据说这个范式好」的唯一途径

延伸阅读

完成阅读,去做练习 →