Lecture 5 · 框架与编排 · Frameworks & Orchestration

Engineering AI Agents · Lecture 05。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

对应 Maps to:Stanford CS329Z Engineering AI Agents(Fall 2026),Lecture 5 · Wed Oct 7 · Frameworks & Orchestration(Diyi Yang、Michael Ryan、John Yang)。阅读材料:Khattab et al., DSPy: Compiling Declarative Language Model Calls into Self-Improving Pipelines(ICLR 2024)。 依据说明:本讲以原课第 5 讲的公开 slides 为主线(“工程化”智能体是什么意思、workflow 与程序的框架、开源智能体 harness、编排与遥测、最佳实践与挑战),结合 DSPy 论文、LangGraph 与 Temporal 的官方文档,以及 slides 引用的材料(arena.ai 的 harness 对比、Meta-Harness、Shepherd、METR 的时间跨度;凡是引用数字的都核对过原文或 slides 上的图)。大纲里原本列了 LlamaIndex,实际的 slides 没有讲它,换成了开源 harness 和 Temporal,本讲照 slides 来。全部实验是本讲义自己设计的,不代表课堂上讲了什么。 配套代码 Code:code/lecture05/(只用 Python 标准库;8 个脚本 + 6 个模块;不需要 API key;每个脚本 1–2 秒) 预计阅读时间:3–4 小时(含运行代码)


0. 本讲导航 · How to read this lecture

0.1 学习目标 Learning goals

slides 给这一讲定了三个目标:认识智能体系统工程里常用的框架;把这些框架引入的抽象和本课讲过的底层系统设计对应起来;评估不同工具在编排和可靠性上的用处。展开来说,学完这一讲你应该能够:

  1. 说出”工程化”一个智能体需要哪三根支柱(数据、系统、评估),并把常见框架放到”控制流在谁手里”这条光谱的正确位置上。
  2. 用 DSPy 的三个概念——签名、模块、优化器——描述一个 LM 程序,并解释 BootstrapFewShot 怎样在没有中间步骤标注的情况下给中间步骤找到示范。
  3. 用 LangGraph 的四个概念——状态、节点、边(含条件边)、START / END——画出一个 workflow 或智能体,并说清楚 reducer、递归上限、检查点各解决什么问题。
  4. 比较几个开源 harness(mini-SWE-agent、OpenHands、Pi、OpenClaw)的设计决策,说出每条决策的代价。
  5. 解释持久化执行(Temporal)的核心机制——活动、工作流、事件历史、重放——以及它要求的纪律(确定性、幂等);会用追踪(tracing)回答”错在哪、慢在哪、钱花在哪”。
  6. 按 slides 的最佳实践做取舍:先简单、让失败看得见并且可恢复、用评估做决定、看懂框架底下发生了什么。

0.2 先修与本讲范围 Prerequisites & scope

默认你已经会 Assumed本讲只给直觉,后面再展开 Previewed only
第 1 讲:智能体循环、workflow vs agent、ReAct设计模式(提示链、路由、并行、orchestrator–workers、evaluator–optimizer)、上下文管理模式(第 6 讲)
第 2 讲:结构化输出、DSPy 签名渲染出来的样子(§6.4)、前缀缓存的定价记忆(第 7 讲)、多智能体(第 8 讲)
第 3 讲:BM25、多跳检索;第 4 讲:工具注册表、文本工具调用协议改写指令的优化器 MIPROv2、GEPA,以及权重优化(第 9 讲)
前置讲 P1(进程与 Shell)、P2(重试、幂等)、P3(缓存、账本、预算)、P4(密钥遮蔽)评估的统计与 LLM-as-judge(第 12–13 讲)

0.3 本讲地图 Map

§0.6 slides 的开场:从"手写反向传播"到"用 PyTorch"
§1  "工程化"智能体:三根支柱、控制流的光谱、框架全景;同一个系统的三种写法          ← 代码 01
§2  DSPy:签名(What)、模块(How)、优化器(自动改进)                            ← 代码 02、03
§3  LangGraph:状态、节点、边;reducer、递归上限、检查点                           ← 代码 04
§4  开源智能体 harness:mini-SWE-agent、OpenHands、Pi、OpenClaw;harness 的差别有多大 ← 代码 05
§5  编排与遥测:Temporal 的持久化执行;追踪与可观测性                              ← 代码 06、07
§6  最佳实践与挑战
§7  用真实模型试一试                                                             ← 代码 08(可选)
§8 小结   §9 常见误区   §10 练习   §11 延伸阅读   §12 下一讲预告

0.4 术语 Terms

术语本讲里的意思
签名(signature)一次 LM 调用的输入输出合同,例如 context, question -> query
模块(module)用某种方式满足签名的可调用单元(Predict、ChainOfThought、ReAct……),可以组合成程序
预测器(predictor)程序里真正调用 LM 的那一步(DSPy 的 Predict);它的示范和指令就是程序的”参数”
示范(demonstration, demo)放在提示词里的输入输出例子(few-shot 例子)
优化器(optimizer;旧名 teleprompter)给定数据和指标,自动改进程序的提示词(或权重)的算法
自举(bootstrap)让程序自己跑训练题,把成功的执行轨迹拆成每一步的示范
状态图(state graph)LangGraph 的程序:共享状态 + 读写状态的节点 + 决定控制流的边
reducer多个更新写同一个字段时怎样合并(追加、相加……)
检查点(checkpoint)每一步之后保存的状态快照;支持中断、恢复、“时间旅行”
harness包在模型外面的那一层:工具、上下文管理、执行方式、循环(第 1 讲)
持久化执行(durable execution)把每一步的结果写进持久的事件历史,崩溃后重放而不是重跑
活动 / 工作流(activity / workflow)Temporal 的两种代码:会失败、会被重试的功能单元 / 把活动串起来的确定性代码
追踪 / span(trace / span)一次请求的完整记录 / 其中的一个步骤(名字、类型、起止时间、属性、状态)

0.5 本讲的代码

这一讲的代码把 slides 介绍的每类框架都写一个”最小但真能跑”的版本:mini_dspy.py(签名、模块、三个优化器)、mini_graph.py(状态图、检查点)、harness_lab.py(两种 Shell、事件日志、卡住检测、树形历史、心跳)、durable.py(持久化执行)、telemetry.py(追踪)。DSPy 的实验跑在第 1 讲虚构百科上的多跳检索问答上(multihop.py):6 种题(2 种 1 跳、4 种 2 跳),训练 30 题、验证 50 题、测试 160 题,检索用第 3 讲从零写的 BM25。智能体图复用第 4 讲的工具注册表和文本协议;Shell 复用前置讲 P1;脱敏复用前置讲 P4。

和前几讲一样的提醒:ToyDSPyLM 是一个”读得懂 DSPy 提示词格式”的模拟模型。它第 2 跳写桥实体还是抄问题、读错率、啰嗦率、会不会模仿示范,都是显式的旋钮(multihop.KNOBS),所以代码 02、03 的具体准确率由旋钮决定。机制性的部分不依赖旋钮:BM25 检索、示范从哪来、优化器收集到了哪些轨迹、编译花了多少次调用、提示词多长;状态图走了哪条路径、检查点和重放;Shell 的语义;token 和成本的账。

0.6 slides 的开场:从”手写反向传播”到”用 PyTorch”

slides 一开始做了个小调查:谁手写过反向传播?谁用 PyTorch 写过?然后点题——框架把一部分底层系统细节抽象掉了;这门课的其余部分要求你实现、理解那些细节,今天的目标是学会这些抽象。

这个类比很贴切。学深度学习时先手推一遍梯度,是为了在 loss.backward() 出问题时知道去哪里找原因;用 PyTorch,是为了不必每次都重写一遍反向传播。本讲义第 1–4 讲手写了智能体循环、LM 封装、检索器、工具调用协议——那是”手推梯度”。这一讲看框架怎样把它们抽象成可复用的部件,以及抽象的代价:你离底层越远,越需要能”打开盖子看一眼”的工具(§2.4 的 inspect_history、§5.5 的追踪)。


1. “工程化”智能体是什么意思 · What does it mean to “engineer” agents?

1.1 三根支柱

slides 把一个 AI 系统拆成三根支柱:

支柱包括什么本课在哪里讲
数据 Data训练数据、轨迹(rollouts)、示范、人类反馈……第 10–11 讲
系统 System(或模型)语言模型、复合 AI 系统、workflow、智能体系统……第 1–9 讲
评估 Evaluation(指标)通过率、精确匹配、LLM 评审、成本 / 延迟……第 12–13 讲

“工程化”的意思是三根支柱都要有:只有系统没有评估,你不知道改动是变好还是变坏;只有评估没有数据,你没法改进。本讲的 DSPy 部分正好把三者连在一起:程序(系统)+ 训练集(数据)+ 指标(评估)→ 优化器。

1.2 控制流在谁手里

第 1 讲的光谱在这里再出现一次:

模型(一次调用)          workflow(程序决定步骤)          agent(模型决定步骤)
控制流在你的程序里,      你设计控制流、决定模型在哪          控制流交给模型:
模型只是其中一小步        几处做决定                        它决定下一步做什么、什么时候停
OpenAI / Anthropic API    DSPy、LangGraph                    各种智能体 harness

每一段都有对应的框架。slides 把框架全景分成三部分:模型 API、workflow / 程序框架(DSPy、LangGraph)、智能体 harness;另外还有两类横跨三部分的工具:可靠性(Temporal)和可观测性(MLflow、LangSmith、Langfuse)。代码 01 演示 1 把这张表和本讲义的代码对应起来。

这些框架真的有人用吗?slides 上的截图:DSPy 约 3.85 万 GitHub star、LangGraph 约 4.28 万、Temporal 约 2.35 万(截图时的数字),招聘网站上也能搜到上千个要求 DSPy 或 LangGraph 的职位。

1.3 同一个系统,三种写法

框架到底抽象掉了什么?代码 01 把一个两跳的检索问答写了三遍:

  • A. 手写:自己拼提示词字符串、自己写 for 循环、自己从回复里切出字段;
  • B. 迷你 DSPy:声明三个签名,forward() 里写控制流;
  • C. 迷你 LangGraph:三个节点(写查询、检索、回答)+ 一条条件边(跳数够了吗?)。

然后逐字节比较三种写法发给模型的消息:

写法EM [95% CI]LM 调用次数发给模型的消息 vs 手写版有效代码行数
A 手写46.0% [33.0, 59.6]150(基准)20
B 迷你 DSPy46.0% [33.0, 59.6]150逐字节相同15
C 迷你 LangGraph46.0% [33.0, 59.6]150逐字节相同27
A2 手写,最后一句多写两个词38.0% [25.9, 51.8]150有差别20

前三行说明一件事:框架不改变系统本身。同样的模型调用、同样的检索、同样的控制流,结果当然一样。差别在于写法,以及每种抽象”附送”的能力:DSPy 的程序知道自己有哪几个预测器(于是优化器有了抓手,§2.5);LangGraph 的程序知道自己的控制流(于是能画出来、能在任意两步之间存检查点,§3.5);手写版什么都不送,但也什么都不挡。

第四行是写代码时的一个意外收获:我们第一次写手写版时,最后一句多写了 “the field” 两个词,EM 就从 23/50 变成了 19/50。在模拟模型里这纯粹是”重新掷骰子”(它的随机数由提示词决定),但它提醒了两件事:真实模型对措辞同样敏感;50 道题上几道题的差别在置信区间之内,不能当作结论——两个区间 [33.0, 59.6] 和 [25.9, 51.8] 大幅重叠。


2. DSPy:从”写提示词”到”写程序” · Programming, not prompting

2.1 动机:手写提示词有什么问题

DSPy 论文的出发点是:当时的 LM 流水线大多由手写的长提示词模板拼起来,这些模板是靠反复试错找到的。问题有三:

  1. 脆弱:换一个模型、换一个任务、改一个步骤,提示词就要重新调;
  2. 不能组合:一个流水线里有好几步,每一步的提示词都要单独调,而它们互相影响;
  3. 没法系统地改进:示范该放哪几个?指令该怎么写?只能凭感觉。

DSPy 的回答是借用深度学习框架的思路:把”提示词”当成程序的参数,由框架根据数据和指标去学,而不是人去写。slides 用三个词概括:签名(What)、模块(How)、优化器。论文摘要报告:编译几分钟后,几行 DSPy 代码就能让 GPT-3.5 和 llama2-13b-chat 自举出的流水线,比标准少样本提示通常分别高出 25% 和 65% 以上,比用专家写的示范的流水线高出 5–46% 和 16–40%;编译到 770M 参数的 T5 这样的小模型上,也能和依赖专家手写提示链的 GPT-3.5 方案相当。

2.2 签名:只声明”要什么”

签名就是一次 LM 调用的输入输出合同:

generate_query = dspy.ChainOfThought("context: list[str], question -> query")

第 2 讲 §6.4 已经见过签名渲染出来的提示词。代码 02 演示 1 打印了这个签名的完整提示词:系统消息里列出输入字段(context、question)和输出字段(reasoning、query),说明每个字段用 [[ ## 字段名 ## ]] 标记,最后一句是任务说明——默认就是 “Given the fields context, question, produce the fields query.”。用户消息里是这次的输入,以及”请先写 reasoning、再写 query、最后写 completed 标记”。

两个值得注意的地方:

  • 默认的指令几乎是空的。签名的字段名本身承载了大部分意思;真正的”提示词工程”交给优化器(本讲的示范,第 9 讲的指令)。
  • ChainOfThought 不是一种特殊的提示技巧,只是在输出字段最前面多了一个 reasoning 字段:同样的输入,Predict 的提示词约 199 token,ChainOfThought 约 232 token,差别仅此而已。程序的其余部分照常只读 query。

2.3 模块:怎样满足合同,以及怎样组合

模块是”用某种方式满足签名”的可调用单元。slides 列出了几个内置模块:

模块怎样满足签名
dspy.Predict直接调用一次 LM
dspy.ChainOfThought先写推理,再写输出字段
dspy.BestOfN同一个模块跑 N 次,按奖励函数挑最好的
dspy.ReAct一个工具循环:思考 → 选工具 → 参数 → 观察,循环结束后从轨迹里抽取答案
dspy.RLM递归语言模型(第 2 讲 §7.5):长输入放在 REPL 的变量里,模型写代码去查看、切分、递归调用自己

模块可以像 PyTorch 的层一样组合。DSPy 教程里经典的多跳程序(multihop.MultiHop):

class MultiHop(dspy.Module):
    def __init__(self, hops=2):
        self.generate_query = [dspy.ChainOfThought("context, question -> query") for _ in range(hops)]
        self.generate_answer = dspy.ChainOfThought("context, question -> answer")

    def forward(self, question):
        context = []
        for hop in range(self.hops):
            query = self.generate_query[hop](context=context, question=question).query
            context = dedup(context + retrieve(query))
        return self.generate_answer(context=context, question=question)

注意 forward() 里的控制流是普通的 Python——这是一个 workflow(程序决定做两跳),不是 agent。框架的基类 Module 提供了一个关键能力:named_predictors() 沿着属性递归地找到程序里所有的预测器——generate_query[0]、generate_query[1]、generate_answer。预测器的示范和指令,就是这个程序的”参数”,就像 named_parameters() 找到神经网络的所有权重。

代码 02 演示 2 比较了几种组合(验证集 50 题:1 跳 15 题、2 跳 35 题):

程序EM1 跳题2 跳题每题调用每题输入 token
Predict,1 跳20.0%67%0%2520
Predict,2 跳32.0%60%20%3875
ChainOfThought,1 跳22.0%67%3%2589
ChainOfThought,2 跳46.0%67%37%31,003
ChainOfThought,3 跳52.0%73%43%41,460

1 跳的程序答不了 2 跳题,这在意料之中。有意思的是第 3 跳:它让 2 跳题从 37% 涨到 43%——第 2 跳”抄了问题”白搜一次之后,第 3 跳还有一次机会写出桥实体。代价是每题的输入 token 涨了 46%。多给步数是在用钱弥补每一步的不可靠;§2.5 换一种办法:让每一步本身更可靠。

2.4 打开盖子看一眼:inspect_history

提示词是框架生成的,这意味着出问题时你看不到它——除非主动去看。DSPy 提供 dspy.inspect_history(n),打印最近 n 次调用的完整提示词和回复。代码 02 演示 3 找了一道答错的 2 跳题:

题目:Who founded the main rival of Pyrent?   标准答案:Ines Marsby(路径 ['Virtrix', 'Ines Marsby'])
两跳的查询:['Who founded the main rival of Pyrent?', 'Who founded the main rival of Pyrent?']
检索到的词条:['Pyrent', 'Axent', 'Lumova']
模型的回答:'Emeka Ostrell'

inspect_history(2) 显示:第 2 跳的预测器看到了 Pyrent 的词条(里面写着 “Its main rival is Virtrix”),但它的推理是 “I will search for the question again”,查询原样抄了问题;检索回来的还是第 1 跳那几个词条;回答时模型只好从上下文里挑了一个人名。这个失败是可以修的——可训练数据里根本没有”第 2 跳该搜什么”的标注。这正是优化器要解决的问题。

slides 在”挑战”一页引用了 Hamel Husain 那篇要求框架”把提示词给我看”的博客:框架把提示词藏起来时,第一件事就是想办法把它拿出来看。

2.5 优化器:数据 + 指标 → 更好的程序

优化器的输入是三样东西:一个程序(学生)、一点训练数据、一个指标。slides 把”数据”和”指标”各放了一页:数据只需要程序的输入和最终输出(这里是 question 和 answer),几十个例子就够;指标是一个 Python 函数 metric(example, prediction, trace=None)。

LabeledFewShot:最简单的做法,从训练集里随机抽 k 个例子当示范。问题是原始标注里只有 question 和 answer,所以只有最后的 generate_answer 能拿到示范,两个写查询的预测器什么也拿不到。

BootstrapFewShot:DSPy 最核心的优化器。思路是:

老师 = 学生的零样本副本 + LabeledFewShot 的示范
对训练集里的每个例子:
    让老师跑一遍,记下整条执行轨迹 trace = [(预测器, 输入, 输出), ...]
    如果 metric(例子, 预测, trace) 通过:
        把 trace 里每一步的 (输入, 输出) 存成那个预测器的示范
    收集够 max_bootstrapped_demos 条成功轨迹就停
学生的每个预测器 = 自举出来的示范 + 用原始标注补足到 max_labeled_demos 条

关键在于:如果最终答案对了,就认为这条轨迹里每一步都”做对了”。于是第 2 跳的查询示范是”自己长出来的”——一条成功轨迹里的第 2 跳查询(例如 “Tove Belwick”),就成了一个”上下文里写着 Ashholt 由 Tove Belwick 创立 → 查询写 Tove Belwick”的示范。一个实现细节:自举某个例子时,要先把这个例子从老师的示范里拿掉,否则老师可能直接”抄”示范里的答案,收集到的是作弊的轨迹(DSPy 源码里也有这一步,mini_dspy.BootstrapFewShot 照做)。

代码 03 演示 2(ChainOfThought 版程序;测试集 160 题):

程序验证 EM测试 EM [95% CI]第 2 跳写对桥实体示范 q0/q1/答编译调用每题输入 token
零样本46.0%41.2% [33.9, 49.0]52%0/0/001,008
LabeledFewShot(k = 4)68.0%63.1% [55.4, 70.2]52%0/0/401,189
BootstrapFewShot,指标 = EM66.0%62.5% [54.8, 69.6]47%4/4/4182,987
BootstrapFewShot,指标 = EM + 中间步骤70.0%80.0% [73.1, 85.5]86%4/4/4333,401
随机搜索(11 个候选,指标 = EM)86.0%80.0% [73.1, 85.5]86%3/3/41,7912,788

逐行读:

  • LabeledFewShot 把测试 EM 从 41% 提到 63%,但第 2 跳写对桥实体的比例一动没动(52% → 52%)。它修好的是”啰嗦”:示范里的答案都很短,模型照着写短答案,精确匹配不再判错。
  • BootstrapFewShot(只看 EM) 给三个预测器都装上了示范,可效果和 LabeledFewShot 差不多,第 2 跳的桥接率甚至降到 47%。§2.6 解释原因。
  • BootstrapFewShot(检查中间步骤):桥接率 86%,测试 EM 80%,编译只用了 33 次 LM 调用。
  • 同样的优化换到 Predict 版程序:测试 EM 31.9% → 69.4%。

2.6 指标就是规格说明

为什么”只看 EM”的自举没教会第 2 跳?代码 03 演示 3 把自举出来的第 2 跳示范逐条打印出来:

BootstrapFewShot,指标 = EM:
   抄问题 ✗  问题:In which city is Lumis headquartered?   →  查询:'In which city is Lumis headquartered?'
   桥接 ✓    问题:In which city is the main rival of Galmoor headquartered?  →  查询:'Velis'
   抄问题 ✗  问题:Who founded Nexion?                     →  查询:'Who founded Nexion?'
   抄问题 ✗  问题:In which city is Triis headquartered?   →  查询:'In which city is Triis headquartered?'

四条里有三条在”抄问题”。原因是:1 跳的题,第 2 跳随便搜什么都能答对。只看最终答案的指标会照单全收这样的轨迹,等于教模型”第 2 跳抄问题也行”。

指标的签名里有 trace 这个参数,就是为了让你在自举时检查中间步骤。DSPy 的多跳教程里有一个指标做了两件额外的检查:每个查询不超过 100 个字符;从第 2 个查询起,不能和问题本身或前面的查询”几乎一样”。multihop.metric_strict 照做(“几乎一样”用词级 F1 ≥ 0.8 判断)。换上它之后,收进来的四条第 2 跳示范全是桥接式的(“Velis”、“Freya Dunow”、“Quoreon”、“Wynhaven”)。它为此多试了几个训练例子:只看 EM 时试了 7 个就收够 4 条,严格指标试了 12 个。

这是这一讲最重要的一句话之一:指标就是规格说明。优化器不知道你”想要”什么,它只会去找让指标变高的东西。你奖励”最终答案对”,它就收集”最终答案对”的轨迹,不管过程多离谱;你想要过程也像样,就得把过程写进指标。第 12–13 讲讲评估时,这句话会以”Goodhart 定律”的面目再出现一次。

2.7 随机搜索、赢家诅咒与成本

BootstrapFewShotWithRandomSearch 在自举外面再套一层搜索:造一批候选程序(零样本、LabeledFewShot、原顺序的自举,再加上若干个”先打乱训练集、随机选 1–4 条轨迹”的自举),在验证集上各评一遍,挑最好的。代码 03 演示 4 打印了 11 个候选:

  • 验证集上最好的是候选 3(验证 86%),它的测试 EM 是 80.0%;
  • 测试集上最好的其实是候选 4(验证 84%,测试 83.8%);
  • 候选 7 验证 74%、测试只有 57.5%。

在 50 道题上挑 11 次最大值,挑出来的那个分数天然偏高——这叫赢家诅咒(winner’s curse):被挑中,部分是因为它真的好,部分是因为它在这 50 道题上运气好。所以要在没有参与挑选的测试集上报告结果(第 12 讲会系统地讲)。

成本有两笔账:

  • 编译:BootstrapFewShot 只花了 18–33 次调用;随机搜索花了 1,791 次——大部分是每个候选都要在验证集上评一遍(50 题 × 3 次调用 × 11 个候选)。
  • 推理:示范让每次推理的输入从 1,008 token 涨到 3,401 token。优化后的程序更准,也每次都更贵(§5.5 会把这笔账算成钱)。第 9 讲会讨论把示范”蒸馏”进权重(DSPy 的 BootstrapFinetune)、以及改写指令而不是加示范的优化器(MIPROv2、GEPA)。

最后补一个 BestOfN 的结果(代码 02 演示 4),它是另一种”花钱换准确率”:

程序EM2 跳题每题调用
ChainOfThought,2 跳(N = 1)46.0%37%3.0
BestOfN,N = 3,奖励 = 简短且有出处70.0%57%4.6
BestOfN,N = 5,奖励 = 简短且有出处74.0%63%4.7
BestOfN,N = 5,奖励 = 永远 1 分46.0%34%3.0

奖励函数不知道标准答案,只检查”答案不超过 3 个词、并且原样出现在检索到的文字里”。它挡住了啰嗦的回答;挡不住瞎猜——猜出来的名字同样出自上下文,“有出处”不等于”正确”。奖励永远给 1 分时,第一次采样就”达标”,等于只换了一次采样。这和第 2 讲 §5.4 的结论一致:重复采样的价值由验证器决定。


3. LangGraph:把控制流画成图 · State graphs

3.1 四个概念

DSPy 关心的是”每一步的提示词怎样变好”;LangGraph 关心的是”步骤之间的控制流怎样组织”。slides 用一个 workflow 的骨架介绍它:

  • 状态(State):整张图共享的工作区——“一个会被图不断修改的对象”。对智能体来说,它常常就是消息历史。
  • 节点(Node):读状态、返回对状态的更新的函数。节点不直接改状态,只说”我要把这几个字段改成什么”。
  • 边(Edge):决定控制流。普通边是”跑完 A 一定去 B”;条件边是”跑完 A 之后,调用一个路由函数看状态,决定去哪”。
  • START / END:每张图都要有起点和终点。

mini_graph.py 把这四样写了出来(StateGraph、add_node、add_edge、add_conditional_edges、compile),外加 LangGraph 真正的看家本领:reducer、递归上限、检查点。

3.2 条件边:路由与循环

slides 用客服工单对比 workflow 和 agent:“给工单分类 → 检索对应的政策 → 起草自动回复”是 workflow;“用工具调查并解决工单”是 agent。代码 04 把前者画成一张图,再加上审阅循环(evaluator–optimizer,第 6 讲)和升级给人的出口:

START → classify ─(紧急?)→ escalate → END
             └──(其他)→ retrieve_policy → draft → review ─(通过)→ send_reply → END
                                            ↑            ├─(不通过且没到 3 稿)┘
                                            └────────────┘
                                                         └─(不通过且已 3 稿)→ escalate

为了把注意力放在控制流上,分类、起草、审阅都是几行规则写的替身(第一稿总是忘了引用政策编号,收到审阅意见后改正;一张工单里有两件事的,草稿永远只回答了一件)。同一张图,五张工单走出了不同的路径(代码 04 演示 2):

工单类别路径结果
T1 重复扣款billingclassify → retrieve_policy → draft → review → draft → review → send_reply发出
T3 全厂断电(URGENT)urgentclassify → escalate升级给人
T4 登不上 + 重复扣款billing…… → draft → review(× 3)→ escalate升级给人

路由和循环都只是”有条件的边”。第 6 讲的五种 workflow 模式,在 LangGraph 里都是这几样东西的组合。

3.3 超步与 reducer:两个节点同时写一个字段

LangGraph 的执行模型借自 Google 的 Pregel:程序按超步(superstep)推进。每一步,所有该跑的节点在同一份状态快照上各自运行,收集它们的更新,然后一起合并进状态;再沿着出边算出下一步该跑哪些节点。于是从 START 连出两条边,两个节点就在同一步里”并行”运行(扇出)。

问题来了:两个节点在同一步里都写了 notes 字段,听谁的?代码 04 演示 3:

reducer = None → InvalidUpdateError: field 'notes' written by ['search_web', 'search_docs'] in the same step; declare a reducer for it
reducer = add  → notes = ['web: E-17 means a corrupted firmware image', 'docs: Policy T-2 covers gateway errors'],write 只跑了一次:2 notes

没有声明 reducer 的字段被两个节点同时写,框架报错,而不是悄悄让后写的覆盖先写的——并行分支丢数据是最难查的 bug 之一。声明了 add 这个 reducer,两条笔记都被保留。消息历史用的 add_messages 也是一个 reducer:新消息追加,而不是替换。

3.4 有环的图怎样保证会停

有环的图可能永远不停。T4 这张工单的审阅永远不会通过。代码 04 演示 4 比较了两种停法:

  • 只靠递归上限(LangGraph 默认 25 步):draft 跑了 12 次,到第 25 步抛出 GraphRecursionError,什么也没交付;
  • 把”最多改 3 稿”写进自己的状态:第 3 稿还不通过,路由函数把它送到 escalate——图走到了一个你设计好的出口。

递归上限是保险丝,不是设计。终止条件(最多几步、最多花多少钱、什么时候交给人)是 workflow 和 agent 设计的一部分(第 6 讲)。

3.5 检查点:中断、恢复、时间旅行

compile(checkpointer=...) 之后,每一个超步结束时,框架把(状态、下一步要跑谁、步数)存成一个检查点,按 thread_id 分开(一个对话一条历史)。这一个机制带来了三种能力(代码 04 演示 5):

(a) 人在回路(human-in-the-loop)。interrupt_before=["send_reply"] 让图停在发送之前:

图停在 ('send_reply',),草稿 = 'Hello, thanks for reaching out. Restart the gateway, then send the device log ... (see Policy T-2)'
人改了草稿、批准后继续 → sent: ...

人用 update_state 修改草稿,再 invoke(None, thread_id=...) 从断点继续。第 14 讲的审批、第 16 讲的”什么时候该打扰用户”,在框架里都落到这个机制上。

(b) 崩溃后从断点继续。我们让 draft 节点第一次调用时抛 TimeoutError。异常往外抛,但最后一个检查点记着 next = ('draft',);修好之后再 invoke,图从 draft 接着跑——classify 和 retrieve_policy 的执行次数都还是 1,没有重跑。§5 的持久化执行把这个想法推到了极致。

(c) 时间旅行。T2 一共存了 9 个检查点。从”分类之后”那个检查点 fork 出一条新 thread、把类别改成 account 再跑:新 thread 用了 Policy A-4,原来的 T2 不受影响。这对调试(“如果当初分类对了,后面会怎样?“)和评估(从同一个中间状态重跑很多次)都很有用。

3.6 智能体循环 = 两个节点的图

slides 的 LangGraph 智能体只有两个节点:

START → agent ─(最后一条消息里有工具调用?)→ tools → agent → … → END

mini_graph.create_agent 用第 4 讲的工具注册表和文本协议拼出了它(代码 04 演示 6):模型先调用 get_weather(city="SF"),看到 “San Francisco: 18 degrees celsius”,再调用 add(a=18, b=5),最后回答 “5 degrees warmer would be 23”。框架没有发明新东西——它把第 1、4 讲手写的循环换成了”可以存档、可以中断、可以画出来”的形式。LangGraph 自带 create_agent 就是这张图。

3.7 DSPy 和 LangGraph:两种抽象,两种取舍

DSPyLangGraph
核心抽象签名 + 模块状态 + 节点 + 边
你在写什么“每一步要什么”(提示词由框架生成)“步骤之间怎么走”(每个节点里你自己调用模型)
控制流普通 Python(forward() 里的 for / if)显式的图(条件边)
附送的能力优化器(示范、指令、权重)检查点:中断、人工审批、恢复、时间旅行;可视化
内置的智能体dspy.ReAct、dspy.RLMcreate_agent
它挡住了什么提示词(要用 inspect_history 才看得到)节点之间的数据流(都塞在一个共享状态里)

slides 的小结:workflow 把模型调用、工具和程序逻辑组合成一个更大的 AI 系统;不同框架暴露不同的抽象;控制流由我们设计、模型在哪里做决定也由我们选;改进需要数据和评估,DSPy 把这一点通过优化器变成了显式的。两者也可以一起用:LangGraph 管流程,节点里调用一个用 DSPy 优化过的模块。


4. 开源智能体 harness · Open-source agent harnesses

4.1 harness 之间差在哪

第 1 讲 §3.8 说过:harness 是”把一切粘在一起的代码”——模型外面的工具、记忆、上下文管理、执行方式。slides 的观点是:造一个智能体有很多设计决策;不同的 harness 为了各自的任务和运行环境做了不同的取舍;好的 harness 在任务成功率、成本、延迟、用户控制之间求平衡,平衡点取决于用途。它举了四个开源 harness 的”招牌决策”:

harness工具历史 / 上下文执行其他
mini-SWE-agent只有 bash完全线性:每一步只往消息列表后面追加每个动作用 subprocess.run 独立执行—
OpenHandsbash、编辑器、浏览器等每一步存成一个事件(磁盘上一个事件一个 JSON 文件),发给模型的消息每次从事件重建持久的终端(tmux 会话:cd、环境变量、虚拟环境在命令之间延续)卡住检测:同样的动作和结果重复 4 次就结束运行
Pi只有 read、write、edit、bash 四个,系统提示不到 1,000 token历史是一棵树:可以回到更早的消息、从那里分叉用启动它的进程的权限运行没有权限系统、计划模式、子智能体(“刻意简单”)
OpenClaw通用个人助手身份来自 Markdown 文件(AGENTS.md、SOUL.md、IDENTITY.md、USER.md、MEMORY.md),全部注入系统提示一个长期运行的 Gateway 进程管理所有聊天渠道每 30 分钟自己醒来一次(心跳),没事就回复 NO_REPLY;每晚 3 点后台任务把当天笔记里值得留下的部分写进 MEMORY.md

harness_lab.py 把每一条都写成了能跑的代码。代码 05 量出了它们的后果。

4.2 无状态 vs 持久的 Shell

同样四条命令,在一个临时目录里真的跑(代码 05 演示 2):

1. mkdir -p app && cd app   2. printf 'print(6*7)\n' > main.py   3. export MODE=debug   4. python3 main.py && echo "mode=[$MODE]"
Shell命令 4 的输出main.py 在哪
无状态(mini-SWE-agent)42 mode=[]./main.py
持久(OpenHands)42 mode=[debug]./app/main.py

无状态 Shell 里,第 1 条命令的 cd 在它自己的子进程结束时就失效了,第 2 条命令把文件写到了工作区根目录;export 同理。模型必须知道 harness 用的是哪种语义。mini-SWE-agent 在系统提示里明确说每条命令在新的子 Shell 里执行,模型就会写成 cd app && python3 main.py——这种写法在两种 Shell 里都输出 42。

持久 Shell 更像人用终端,但多了一类故障。PersistentShell 的实现(读输出直到看见一个独一无二的结束标记)里处理了几种情况:命令读 stdin 会”吃掉”结束标记,所以把命令的 stdin 接到 /dev/null;命令超时,就杀掉整个进程组、重启 Shell。演示里:

之前 cwd=app mode=[debug];sleep 3 超时(限 1 秒),退出码 124,重启 1 次;之后 cwd=workspace-root mode=[]

重启之后,目录、变量、激活的虚拟环境全都没了。harness 必须明确告诉模型状态丢了,否则它会在错误的目录里继续干活。无状态的好处正是没有这类故障:每条命令都从同一个干净的起点开始,可复现,也更容易换成 docker exec 这类沙箱执行。

4.3 历史的形状:线性、事件日志、树

代码 05 演示 3 构造了一条 40 步的编程轨迹:每步一个动作(约 15 token)和一段测试输出(约 400 token);从第 20 步起模型陷进循环——同一个命令、同一个报错。

做法跑了几步整个运行的输入 token最后的上下文 token
线性历史(mini-SWE-agent)40416,46021,021
事件日志 + 压缩视图(超过 6,000 token 就把旧事件换成摘要)40152,3845,521
线性历史 + 卡住检测(重复 4 次就停)24150,30012,765
  • 线性历史最简单,但每一步都重发全部历史,总输入随步数平方增长(第 1 讲 §3.5)。
  • 事件日志(OpenHands)把”发生了什么”和”发给模型什么”分开:事件只追加、不修改,磁盘上始终有完整的 87 个事件;压缩只是追加一个 condensation 事件,在视图里用摘要替换一段旧事件。完整记录留着,调试、审计、崩溃后恢复都靠它(EventLog.load 重新读目录就回到原状态)。事件日志还天然支持订阅者:每追加一个事件就通知监控成本、检查安全的观察者——slides 特意提到这一点。
  • 卡住检测在第 24 步就停了,省下的是那 16 步注定徒劳的循环。它故意保守:只认”完全一样”的重复,误判卡住比漏判更伤。
  • 树(Pi):一次走歪的尝试带来两大段输出,线性地接着往下写,上下文 3,021 token;回到任务消息重新分叉,上下文 277 token,而树里仍有全部 7 个节点,需要时可以切回去看。

第 6 讲会把这些归纳成上下文管理的五种模式(事件日志、记忆卸载、压缩、观察遮蔽、上下文隔离)。

4.4 心跳助手的账

OpenClaw 的心跳是一个很好的成本案例:每 30 分钟醒一次,一天 48 次,每次都要发送整个系统提示(四个身份文件 + MEMORY.md)。代码 05 演示 4 算了一个月的账(价格:输入每百万 token 5 美元、输出 25 美元;写缓存 1.25 倍(5 分钟寿命)或 2 倍(1 小时寿命),读缓存 0.1 倍):

定价方式第 1 天(美分)第 30 天(美分)30 天(美元)1 万个用户 × 30 天(美元)
不缓存7.5457.659.77997,791
缓存,寿命 5 分钟9.2571.8912.170121,695
缓存,寿命 1 小时1.688.671.55215,521

两个不那么显然的结论:

  1. 心跳间隔比缓存寿命长时,“开了缓存”反而更贵。30 分钟一次的心跳,5 分钟的缓存每次都已过期,每次都要重新写缓存(1.25 倍),比不缓存还贵 25%。换成 1 小时的缓存:每天 MEMORY.md 变一次,写 1 次、读 47 次,便宜了六倍多。缓存的寿命要和调用的节奏匹配(第 2 讲 §7.3、前置讲 P3)。
  2. 记忆本身有持续的成本。我们的玩具身份文件只有 284 token(真实的往往大得多),但 MEMORY.md 每晚长 72 token,30 天后系统提示变成 2,372 token,第 30 天的成本是第 1 天的近 8 倍。每一条被记住的东西,都要在之后的每一次心跳里付钱——记忆需要维护和遗忘(第 7 讲)。

4.5 harness 的差别有多大

slides 引用了 arena.ai 的对比(Terminal-Bench 2,同一个模型配三个 harness):

模型PiCodexClaude Code
Claude Fable 571.1%(每次 1.08 美元)72.2%(0.98)75.6%(1.55)
Claude Opus 4.872.2%(0.76)72.2%(0.85)68.9%(0.90)
Claude Sonnet 4.665.6%(0.61)63.3%(0.55)62.2%(0.67)
Claude Haiku 4.547.8%(0.25)31.1%(0.21)41.1%(0.25)

两点值得注意:没有一个 harness 在所有模型上都最好;模型越弱,harness 的影响越大——Haiku 4.5 上最好和最差的 harness 差了 16.7 个百分点,Fable 5 上只差 4.5 个点。

既然 harness 这么重要,能不能让 AI 来写 harness?slides 介绍了 Meta-Harness(2026):一个智能体读取文件系统里所有历史候选的源代码、执行轨迹和得分,提出新的 harness 代码,评估后把全部日志写回文件系统,如此循环。论文报告:在文本分类上,它只用 4 次评估就追平了次优方法的最终准确率;在 Terminal-Bench 2 上配 Claude Haiku 4.5,它找到的 harness 达到 37.6%,高于该论文里所有人写的 harness(Goose 35.5%、Terminus-KIRA 33.7%、mini-SWE-agent 29.8%、Terminus-2 28.3%、Claude Code 27.5%)。注意同样是”Claude Code + Haiku 4.5”,两份报告的数字(41.1% 和 27.5%)差了十几个点——不同时间、不同设置下的 harness 评估不能直接对比,这本身就说明评估对设置很敏感(第 12 讲)。

slides 的收尾:造智能体没有唯一的正确方式;这门课里你会亲手试这些设计决策;“也许你能造出下一个流行的 harness”。


5. 编排与遥测 · Orchestration & telemetry

5.1 为什么需要

slides 用 METR 的”时间跨度”图开场:METR 衡量模型能以 50% 成功率完成多长的任务(按人类专家完成所需的时间计),这个长度大约每 7 个月翻一番。slides 也提醒,那张图量的是”人类的时间”;但智能体本身的运行时间同样在变长——几十分钟到几小时。于是:

  • 支撑它们的基础设施必须可靠:跑到一半的机器重启、API 超时、限流,都不该让几十分钟的工作从头再来;
  • 需要日志和可观测性来调试:一次运行里有几十次模型调用和工具调用,出了问题要能定位;
  • 这些都是软件工程里”吃过亏才学会”的经验,可以直接迁移到智能体上。

5.2 Temporal 的执行模型

slides 介绍的可靠性工具是 Temporal(一个开源的”持久化执行”平台)。它的执行模型:

你的代码(Client)──启动工作流──→ Temporal 服务器:任务队列(工作流队列、活动队列)+ 执行历史与存储
                                         ↑↓
                            Workers:从队列取任务,执行你的工作流代码和活动代码
  • 活动(Activity):会失败的功能单元——调用 LLM、调用 API、发邮件。按重试策略自动重试。
  • 工作流(Workflow):把活动串起来的代码。它必须是确定性的。
  • 事件历史:服务器记下”调度了哪个活动、它返回了什么”。
  • Worker:真正执行代码的进程。它崩了,另一个 worker 读事件历史、重放工作流代码:已经完成的活动不再执行,直接返回历史里记录的结果,然后从断点继续。

slides 一步一步演示了怎样写一个持久的工作流(定义活动 → 定义工作流 → 起一个 worker → 运行),以及重试、“由服务器负责计时”(工作流里 sleep 一个月也没关系,因为等待由服务器记录,不占 worker)。durable.py 用约 200 行把”记录 → 崩溃 → 重放”这条主线写了出来:历史就是一个 JSONL 文件(一行一个事件),重试的等待用虚拟时钟。

5.3 崩溃、重试、至少一次、确定性

代码 06 用一个”写调研报告”的智能体:规划(LLM,1,500 token)→ 搜索 × 2(不稳定的 API)→ 总结(LLM,4,000 token)→ 发邮件(有副作用)。

(a) 跑到第 4 步机器重启(总结做完、邮件还没发):

做法LLM token搜索次数邮件
普通脚本:崩溃后从头重跑11,00041
持久化执行:崩溃后重放历史5,50021

持久化执行恢复时重放了 plan、search、search、summarize 四个活动(直接返回记录的结果),只真正执行了 send_email。再运行第三次,历史里已有 WorkflowCompleted,直接返回记录的结果,什么都不执行。

(b) 重试策略照错误类型办事(我们设的策略:初始间隔 1 秒、退避系数 2、最多 5 次、ValueError 不重试):

t =  0s  第 1 次失败(TimeoutError)→ 等 1 秒
t =  1s  第 2 次失败(TimeoutError)→ 等 2 秒
t =  3s  第 3 次失败(TimeoutError)→ 等 4 秒      ← 第 4 次成功,虚拟时间 7 秒

一直超时的话,试满 5 次后活动抛出 ActivityError,工作流捕获它、在报告里注明搜索失败(补救分支);空查询抛的 ValueError 只试 1 次就失败——参数错误重试一万次也不会对(前置讲 P2 §5)。作为对照,Temporal 给活动的默认重试策略是:初始间隔 1 秒、退避系数 2.0、最大间隔为初始间隔的 100 倍、不限次数、没有不可重试的错误类型;工作流本身默认不重试。“不限次数”意味着你必须自己想清楚哪些错误不该重试。

(c) 至少执行一次。如果 worker 恰好死在”邮件已经发出、结果还没写进历史”之间,恢复后这个活动会再执行一次:

外部服务恢复后用户收到的邮件
邮件服务不去重completed2
邮件服务按幂等键去重completed(第二次返回 “duplicate ignored”)1

持久化执行框架保证的是”工作流最终完成、每个活动的结果只被采用一次”,但活动本身是至少执行一次(at-least-once)。扣款、下单、发消息这类工具必须自己做幂等:带一个幂等键,让外部服务去重(前置讲 P2 §5.4)。

(d) 确定性。重放要求工作流代码每次都做出同样的决定:

工作流写法第一次(9 点)恢复(13 点)
直接读系统时间(上午走”搜索”分支,下午走”规划”分支)崩溃NonDeterminismError:历史里第 1 步是 search,代码现在要 plan
用 ctx.now()(第一次运行时记进历史,重放时读回来)崩溃完成
崩溃后部署了新版本的工作流代码崩溃NonDeterminismError

所以工作流里不能直接读时间、取随机数、调用外部服务——这些都要通过框架的 API(时间、随机数作为”标记”记进历史;外部调用写成活动)。中途改代码同样会对不上,Temporal 用版本标记(patching)让新旧代码并存。这些纪律就是持久化执行的代价;对几秒钟的单次调用不值得,对跑几十分钟、花真钱、会改外部世界的智能体值得。§3.5 的 LangGraph 检查点是同一思想的轻量版:在节点之间存状态,而不是在每个外部调用之间。

5.4 可观测性:追踪

slides 列了三个可观测性工具:MLflow、LangSmith、Langfuse(以及一张 MLflow 追踪 DSPy 程序的截图)。它们共同的数据模型来自分布式系统的追踪(OpenTelemetry 标准):

  • 一次请求是一个 trace,它是一棵 span 树;
  • 每个 span 是一个步骤:名字、类型(LLM / 检索 / 工具 / 链)、起止时间、父 span、属性(输入、输出、token、成本……)、状态(成功 / 出错)。

telemetry.py 从零写了一个追踪器。框架的”一行代码开启追踪”是怎样做到的?自动埋点:把框架的几个入口方法替换成”先开 span、再调用原方法”的包装函数(monkey patching)。代码 07 就这样给 DSPy 程序的每个预测器和检索器埋了点,业务代码一行没改。写代码时还撞上了一个真实的坑:检索器是用 rm(query) 调用的,而 Python 对 obj(...) 这种调用会到类上找 __call__,不看实例属性——给实例设一个 __call__ 属性,埋点会悄悄失效。只能替换类上的方法。这也说明了自动埋点的弱点:它依赖框架的内部结构,框架一升级,埋点可能悄悄不工作了。

一道答错的题的追踪(代码 07 演示 1):

MultiHop [chain] 2.97s answer='Ashmere'
  generate_query[0] [module] 0.92s query='Trieon'
    llm [llm] 0.92s tokens_in=241 tokens_out=40
  retrieve [retriever] 0.00s query='Trieon' results=['Trieon']
  generate_query[1] [module] 1.06s query='In which city was the founder of Trieon born?'
    llm [llm] 1.06s tokens_in=282 tokens_out=49
  retrieve [retriever] 0.00s query='In which city was the founder of Trieon born?' results=['Trieon', 'Ashholt (city)', 'Ashmere (city)']
  generate_answer [module] 0.98s answer='Ashmere'
    llm [llm] 0.98s tokens_in=364 tokens_out=43

一眼就能看出:第 2 跳抄了问题,检索回来的是两个城市,模型从里面挑了一个。

汇总回答”慢在哪、钱花在哪”(测试集 160 题;延迟是模拟的:“首 token 0.3 秒 + 逐 token 生成”):零样本程序每题 0.833 美分、p50 2.97 秒、p95 3.12 秒;优化后(§2.6 的程序)每题 2.030 美分、p50 3.22 秒、p95 3.34 秒。成本几乎全部是 LLM 的输入 token(161,215 → 544,193)——示范变长了。准确率 41% → 80% 值不值这个钱,是一个要拿数字说话的产品决定。看 p95 而不只看平均:用户记住的是那几次慢的。

错误分析:只看追踪里记下的东西(检索结果、查询、答案),把每道错题归到第一个出问题的环节:

失败环节零样本优化后
第 1 跳检索没找到起点00
第 2 跳查询抄了问题499
第 2 跳查询写错了实体15
阅读(读错 / 猜错)1213
答案格式(啰嗦)325
合计9432

优化消灭了大部分”抄问题”和”啰嗦”,剩下最多的是”读错 / 猜错”——下一步该优化哪里,追踪已经告诉你了。这是第 12、13 讲错误分析的起点。

追踪的风险:追踪会原样记下提示词和工具输出,包括连接串和密钥。代码 07 演示 4(b) 导出同一个 span,不脱敏时 JSONL 里是 connected to postgres://admin:[email protected]/prod with sk-live-abcdef123456,写入前用前置讲 P4 的 redact 过一遍,就变成 connected to [已遮蔽:数据库连接串里的密码] with [已遮蔽:API key]。还要控制谁能读追踪数据:可观测性本身也是攻击面。


6. 最佳实践与挑战 · Best practices & challenges

slides 的最后一部分是四条最佳实践和三个挑战。

1. 保持简单(Keep it simple)。不一定要造智能体,有时 workflow 才对(第 1 讲 §4.3);真要造智能体,仔细想清楚该给它哪些工具(第 4 讲:工具越多越容易选错);如果你有大量数据,甚至可以端到端地优化一个模型(第 9 讲)。

2. 让失败看得见、可恢复。追踪模型调用、工具调用、状态变化和错误(§5.4);设置超时、重试上限和执行预算——代码 07 演示 4(a) 给优化后的程序设了 0.5 美元的预算,跑完 25 题后停下,追踪里留下一个明确的错误 span;记录输出、诊断失败;监控系统状态。slides 还提了一句容易被忽略的话:“你可以把错误抛回给智能体,让它再试一次”——第 4 讲的错误说明、§2 的解析重试、§5.3 的补救分支都是这个思路。slides 举的例子是 Shepherd(2026):一个”元智能体”观察另一个智能体的执行轨迹,可以拦截、回滚、分叉(例如”测试挂了就回滚这次编辑、重试”);论文报告在 CooperBench 的结对编程任务上,没有元智能体时通过率 28.8%,用 Sonnet 4.6 当元智能体 45.3%,用 Opus 4.7 54.7%。

3. 跑评估。做设计决策最好的依据是评估;评估让优化成为可能(§2.5);搭一个像样的评估是造 AI 系统最重要的步骤之一;指标要选好(准确率、成本、延迟……);测试有代表性的任务、边界情况和失败场景。slides 特别强调:除非你在造通用的编程智能体 harness,或者在验证一项通用的智能体改进,否则标准的编程 benchmark 不是你该去刷的——去建你自己领域的评估。

4. 工程是一个循环:搭建 → 运行 → 评估 → 改进 → 再搭建。从简单开始,每加一分复杂度都要有理由;为失败和恢复而设计;用证据评估质量、成本和延迟;完全理解你的系统是怎样工作的。

挑战与局限:

  • 抽象让你离底层更远——确保你知道盖子底下发生了什么(§2.4 的 inspect_history、§5.4 的追踪);
  • 复杂系统里很难定位失败来自哪里——一定要看追踪(代码 07 的错误分析);
  • 建在框架上意味着多了依赖:外部库的升级、弃用、内部结构的变化(自动埋点悄悄失效就是一例)。

7. 用真实模型试一试 · Try it on a real model

代码 08(可选)把多跳程序交给真实的 Claude:先跑零样本 ChainOfThought 版,再用真实模型自己跑 BootstrapFewShot(严格指标),在验证集前 10 题上比较:

pip install anthropic
python3 08_real_llm.py

--toy 不联网,用模拟模型跑同一个流程。值得看的是:真实模型能不能按字段标记格式回答(解析失败会触发 mini_dspy 的”带着错误说明重试一次”);零样本时第 2 跳会不会也抄问题、答案会不会啰嗦;示范对它的作用主要在哪里。一个值得检验的猜想:强模型零样本时多半已经会写桥实体,示范的作用更多在格式和一致性上——如果是这样,说明优化的收益取决于模型本来缺什么。10 道题的置信区间很宽,看大的趋势和具体的失败样子,不要看小数点。


8. 小结 · Summary

  1. 工程化 = 系统 + 数据 + 评估。框架按”控制流在谁手里”分三部分:模型 API、workflow / 程序框架(DSPy、LangGraph)、智能体 harness;另有横跨它们的可靠性(Temporal)和可观测性(MLflow、LangSmith、Langfuse)工具。框架不改变系统本身——同一个系统的三种写法发给模型的消息逐字节相同——它改变的是写法和附送的能力。
  2. DSPy:签名声明每一步要什么,模块决定怎样满足(Predict、ChainOfThought、BestOfN、ReAct、RLM),模块像神经网络的层一样组合;预测器的示范和指令是程序的”参数”。BootstrapFewShot 用成功的执行轨迹给中间步骤生出示范;指标就是规格说明——只看最终答案会收进过程离谱的轨迹;在验证集上挑候选有赢家诅咒;示范让每次推理更贵。
  3. LangGraph:状态、节点、边(含条件边)、START / END;按超步执行,同一步写同一字段需要 reducer;终止条件要写进自己的设计,递归上限只是保险丝;检查点带来人工审批、崩溃恢复和时间旅行;智能体循环是两个节点的图。
  4. harness:无状态 vs 持久 Shell、线性 vs 事件日志 vs 树、卡住检测、心跳——每条决策都有成本和故障模式;同一个模型换 harness,成功率可以差十几个点,模型越弱差得越多。
  5. 编排与遥测:持久化执行把每个活动的结果写进事件历史,崩溃后重放而不是重跑;代价是工作流代码必须确定、有副作用的活动必须幂等(至少执行一次)。追踪把一次运行变成 span 树,回答错在哪、慢在哪、钱花在哪;写入前要脱敏。
  6. 最佳实践:保持简单;让失败看得见、可恢复(超时、重试上限、预算、把错误抛回给智能体);用自己领域的评估做决定;理解盖子底下发生了什么。

9. 常见误区 · Common pitfalls

  • “用了框架,效果就会更好。” 框架不改变系统:代码 01 里三种写法的消息逐字节相同、准确率相同。效果来自你设计的系统、数据和评估。
  • “DSPy 的优化器会自动搞定一切。” 优化器只会优化你写的指标。只看最终答案时,它收进了 3 条”第 2 跳抄问题”的轨迹(代码 03)。
  • “验证集上最好的那个就是最好的。” 在 50 道题上挑 11 次最大值,挑出来的分数偏高(验证 86%、测试 80%)。在没参与挑选的测试集上报告。
  • “优化后更准,所以更好。” 也更贵:每题输入 token 1,008 → 3,401,成本 0.833 → 2.030 美分。值不值要拿数字说话。
  • “递归上限就是停止条件。” 它是保险丝:触发时抛异常、什么都不交付(代码 04)。把”最多几次、然后怎样”写进自己的状态和路由里。
  • “并行的分支各写各的,不会冲突。” 同一步写同一个字段时,没有 reducer 就会丢数据(LangGraph 选择报错)。
  • “持久 Shell 总比无状态好。” 它多了一类故障:超时重启后状态全丢,harness 必须告诉模型。无状态更可复现、更容易沙箱化。
  • “开了前缀缓存就省钱。” 调用间隔比缓存寿命长时,缓存每次都过期,反而更贵(代码 05:12.17 vs 9.78 美元)。
  • “用了持久化执行,副作用就只会发生一次。” 活动是至少执行一次的;扣款、发邮件要靠幂等键去重(代码 06:2 封 vs 1 封)。
  • “工作流代码里读一下当前时间没关系。” 重放时会做出不同的决定 → NonDeterminismError。通过框架的 API 取时间和随机数。
  • “追踪越详细越好。” 追踪会原样记下密钥和隐私数据;写入前脱敏,控制谁能读。
  • “标准 benchmark 上分数高,说明适合我的场景。” slides:除非你在造通用 harness,否则去建自己领域的评估。

10. 练习 · Exercises

练习 1(签名):为下面三个步骤各写一个 DSPy 签名,并说明哪一步适合用 ChainOfThought:(a) 给一封邮件判断优先级(high / medium / low);(b) 从邮件里抽出会议时间和参会人;(c) 根据检索到的政策条款,判断一张报销单是否合规并给出理由。

参考答案(之一) (a) `email -> priority: Literal['high','medium','low']`;(b) `email -> meeting_time: str, attendees: list[str]`;(c) `policy_clauses: list[str], expense_report -> compliant: bool, reason: str`。(c) 最适合 ChainOfThought:要把报销单的每一项和条款逐条比对,先写推理能减少跳步;(a) 通常不需要(而且推理会让每次调用变贵、变慢),除非评估显示它在难例上有帮助。注意 (c) 的 `reason` 和 CoT 的 `reasoning` 不同:前者是给用户看的产品输出,后者是给模型自己用的草稿。

练习 2(为什么 LabeledFewShot 救不了第 2 跳):代码 03 里 LabeledFewShot 让测试 EM 从 41% 涨到 63%,但第 2 跳桥接率一点没变。用”原始标注里有哪些字段”解释,并说明 BootstrapFewShot 是怎样绕过这个问题的。

参考答案 原始标注只有 `question` 和 `answer`。`generate_query` 的签名需要 `context, question -> query`,标注里没有 `query`(也没有中间的 `context`),所以它拿不到任何示范;只有 `generate_answer` 能拿到(它需要的 `answer` 在标注里),于是只修好了答案的格式。BootstrapFewShot 让老师程序真的跑一遍训练题,执行轨迹里记下了每个预测器这次的输入(包括检索到的 context)和输出(包括写出的 query);答案对了,就把这些中间的 (输入, 输出) 当示范——中间步骤的"标注"是从成功的运行里自己长出来的。

练习 3(指标就是规格说明):写一个用于自举的指标,除了答案正确,还要求:(a) 第 2 跳检索回来的段落里包含标准答案;(b) 每个查询不超过 6 个词。分别说明这两条各自在防什么,并指出 (a) 在什么情况下会误杀好的轨迹。

参考答案(之一) ```python def metric(ex, pred, trace=None): if not exact_match(pred.answer, ex.answer): return False if trace is None: return True queries = [out["query"] for _, _, out in trace if "query" in out] if any(len(q.split()) > 6 for q in queries): return False return any(ex.answer in c for c in pred.context) ``` (a) 防"答对但不是读出来的"——蒙对的轨迹(例如上下文里正好有一个同类的名字)不该被当成示范;(b) 防"把整个问题抄成查询",迫使查询像一个实体名。(a) 的误杀:答案需要推理或换算才能得到时(例如"成立了多少年"),答案字符串不会原样出现在段落里;1 跳题的答案出现在第 1 跳的段落里,所以这里检查的是全部上下文而不是只看第 2 跳。

练习 4(赢家诅咒):随机搜索有 11 个候选,它们的真实准确率都是 70%,验证集 50 题。挑出来的那个候选,它的验证分大约会是多少?用”每个候选的验证分近似服从均值 0.7、标准差 √(0.7·0.3/50) ≈ 0.065 的正态分布”估一下。这对你报告结果有什么启示?

参考答案 11 个独立正态样本的最大值,期望大约在均值以上 1.59 个标准差处,约 0.7 + 1.59 × 0.065 ≈ 0.80。也就是说,**即使所有候选一样好**,挑出来的验证分也会虚高约 10 个百分点。启示:(1) 在没参与挑选的测试集上报告;(2) 验证集太小时,候选之间的差别大部分是噪声,挑选本身价值有限;(3) 候选越多,虚高越严重——这和第 12 讲"在测试集上反复调参"是同一个问题。

练习 5(reducer):一张图里,search_web 和 search_docs 在同一步运行,都返回 {"notes": [...], "best_source": "..."}。为两个字段各选一个合适的 reducer(或者说明为什么不该让两个节点都写它)。

参考答案 `notes`:用列表拼接(`add`)——两边的笔记都要保留。`best_source`:它是一个"结论",两个分支各自都只看到一半信息,谁写都不对;合理的设计是两个分支都不写它,而是在后面加一个 `merge` 节点读两边的笔记、再写 `best_source`。如果非要并行写,也要写一个明确的 reducer(例如按某个分数取最大),而不是依赖"后写的覆盖先写的"——那取决于执行顺序,是不确定的。

练习 6(停止条件):给代码 04 的工单图再加两条停止条件:(a) 整个工单处理的 LLM 成本超过 2 美分就升级给人;(b) 同一个审阅意见连续出现两次就不再重写。说明它们要写在状态里的哪些字段、哪条边的路由函数里。

参考答案(之一) 状态里加 `cost`(reducer = 相加;每个调用 LLM 的节点返回自己这次的花费)和 `feedback_history`(reducer = 列表拼接)。在 review 之后的路由函数里按顺序判断:通过 → send_reply;`cost > 0.02` → escalate;`feedback_history` 最后两条相同 → escalate;`revisions >= 3` → escalate;否则 → draft。(b) 是卡住检测(§4.3)的 workflow 版本:同样的反馈说明重写没有效果,再写一稿是浪费。

练习 7(harness 的语义):一个模型在训练时习惯了持久的终端,被放进 mini-SWE-agent 风格的无状态 harness。列出它最可能犯的三个错误,以及 harness 可以怎样帮它。

参考答案(之一) (1) 单独一条 `cd src`,然后在下一条命令里用相对路径——文件写错地方或找不到;(2) `export` 或 `source venv/bin/activate` 之后假设它们还有效——用错 Python、找不到依赖;(3) 启动一个后台服务(`python server.py &`)然后在下一条命令里访问它——无状态执行可能在命令结束时就把它杀了。harness 的帮法:系统提示里明说每条命令在新的子 Shell 里执行、要用 `&&` 串起来;观察里回显当前目录;检测到单独的 `cd` / `export` 时在观察里加一句提醒。代码 05 演示 2 的那张表就是这类错误的样子。

练习 8(心跳与缓存):代码 05 的心跳助手如果改成每 4 分钟一次(一天 360 次),在”不缓存""5 分钟缓存""1 小时缓存”三种方式下,每天的输入成本大约是原来(每 30 分钟一次)的几倍?只考虑前缀,忽略新内容。

参考答案 设前缀 P 个 token。原来:不缓存 48P;5 分钟缓存 48 × 1.25P = 60P;1 小时缓存 2P + 47 × 0.1P = 6.7P。改成每 4 分钟:不缓存 360P(7.5 倍);5 分钟缓存——间隔 4 分钟 < 5 分钟,缓存一直是热的,每天写 1 次(MEMORY.md 变时)、读 359 次:1.25P + 35.9P ≈ 37.2P(约原来 60P 的 0.62 倍,**反而更便宜**);1 小时缓存 2P + 35.9P = 37.9P(约 5.7 倍)。结论:缓存的价值取决于调用节奏和缓存寿命的关系;更频繁的心跳在"不缓存"下贵得多,在缓存下的增量主要来自读缓存的 0.1 倍。

练习 9(持久化执行的纪律):下面的工作流代码有三处会破坏重放。指出它们,并改正:

def workflow(ctx, user_id):
    if random.random() < 0.1:                    # 10% 的用户做 A/B 测试
        variant = "B"
    else:
        variant = "A"
    profile = requests.get(f"https://api/users/{user_id}").json()
    draft = ctx.execute_activity(write_email, profile, variant)
    if datetime.now().hour >= 18:
        ctx.sleep(12 * 3600)                     # 晚上就等到第二天早上
    return ctx.execute_activity(send_email, draft)
参考答案 (1) `random.random()`:重放时可能抽到不同的值 → 走不同的分支;改成 `ctx.random(rng)`(或者按 user_id 的哈希分组,本身就确定)。(2) `requests.get` 直接在工作流里调用外部服务:重放时会再调一次,而且结果可能变了;改成一个活动 `ctx.execute_activity(fetch_profile, user_id)`。(3) `datetime.now()`:恢复时可能已经过了 18 点 → 分支不同;改成 `ctx.now()`。另外 `send_email` 是有副作用的活动,应该带幂等键(例如 `f"{user_id}:{workflow_id}"`)。

练习 10(追踪的错误分析):代码 07 的错误分析按”第一个出问题的环节”归类。为什么要用”第一个”?如果一道题第 2 跳抄了问题、答案又啰嗦,按”所有出问题的环节”计数会有什么问题?

参考答案 因为失败是**级联**的:第 2 跳抄了问题 → 上下文里没有答案 → 阅读器只能猜。后面的"阅读错误"是前面错误的后果,不是独立的病因;修好第一个环节,后面的往往自动消失。按所有环节计数会把同一个病因数好几次,让下游环节看起来比实际更糟,把优化的注意力引到错误的地方。"第一个出问题的环节"的前提是你知道程序的执行顺序、并且每个环节都有一个能从追踪里判断的"对 / 错"标准——这正是第 12 讲要你为每个组件设计的东西。

11. 延伸阅读 · Further reading

本讲的阅读材料

  • O. Khattab et al., DSPy: Compiling Declarative Language Model Calls into Self-Improving Pipelines, ICLR 2024.

框架

  • DSPy 文档:签名、模块、优化器(BootstrapFewShot、BootstrapFewShotWithRandomSearch、MIPROv2、GEPA)、dspy.inspect_history。
  • LangGraph 文档:Graph API(State、Nodes、Edges、reducers)、persistence(checkpointers、threads、time travel)、human-in-the-loop。
  • Temporal 文档:Workflows、Activities、Workers、Retry Policies、Event History 与 replay、确定性约束与版本。
  • OpenTelemetry 的 tracing 概念;MLflow Tracing、LangSmith、Langfuse 的文档。

harness

  • mini-SWE-agent(SWE-agent 团队的极简 harness)、OpenHands(Wang et al., ICLR 2025;第 15 讲的阅读材料)、Pi、OpenClaw 的仓库和文档。
  • arena.ai, Coding agents: the harness tax(slides 的 harness 对比图)。
  • Meta-Harness(arXiv 2603.28052, 2026);Shepherd(arXiv 2605.10913, 2026)。

背景

  • METR, Measuring AI Ability to Complete Long Tasks(2025)与持续更新的 time horizons 页面。
  • H. Husain 关于”把提示词给我看”的博客(hamel.dev/blog/posts/prompt)。

12. 下一讲预告 · Next lecture

第 6 讲 智能体设计模式与脚手架:这一讲学了”用什么写”,下一讲学”写成什么样”。slides 会先回顾 workflow 和 agent 的区别,然后讲五种 workflow 模式——提示链、路由、并行化(分段 / 投票)、orchestrator–workers、evaluator–optimizer——以及它们怎样组合成真实系统(STORM、Archon);再讲智能体的推理 / 行动模式:ReAct(以及”要微调才最好”的发现)、Reflexion 与自我反思的局限、CRITIC(带工具的验证器)、CodeAct(用代码当动作)、ReWOO 与 LLMCompiler(先规划再执行、并行调用);最后是上下文管理的五种模式(事件日志、记忆卸载、压缩、观察遮蔽、上下文隔离),并以 Pi 为案例拆解一个生产级 harness 的设计决策。配套代码会在同一组任务上比较这些模式的准确率、成本和延迟。

← 返回课程目录