Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Agent 与环境交互:下一步,要看世界发生了什么

“请帮我打印一张测试页。”

助手检查了远端服务,请你装上纸,然后收到一句“好的,马上”。现在,它可以宣布完成了吗?

还不行。纸可能还在抽屉里,打印队列可能堵着,测试页也还没出来。即使它说得头头是道,任务仍停留在原地。

在第 34 章中,我们已经搭过“选择动作—执行工具—读取结果”的循环。这里继续追问:当变化需要等待,一部分操作掌握在别人手里,还有多个执行者同时工作时,这个循环怎样才能持续推进?

我们会让一个真实小模型操作可运行的打印机模拟环境,再枚举两个执行者争用同一名额的情况。模型没有完成任务;这些失败能帮助我们看清,困难究竟出在读取反馈、选择行动,还是环境本身。

前置内容还包括工具调用与工作流、应用评估与可靠性和长上下文与记忆。资料核查截至 2026 年 10 月 20 日。文中选取五篇研究连接交互接口、用户协作、长任务和可验证环境;本书实验不复现这些论文的榜单成绩。

1. 世界的状态,和助手知道的事

先把打印任务拆成三个容易混淆的东西。

对象在打印任务里是什么
环境状态服务是否启用,纸是否装好,已经打印了几页
观察结果工具这一次告诉助手的内容
交互历史助手已经做过的动作,以及收到过的结果

环境可能已经变了,助手却还不知道;助手也可能以为某件事已经完成,环境实际并没有变化。“我请求用户装纸”属于交互历史,“纸已装好”属于环境状态。两者之间还隔着用户的行动。

用 sts_t 表示第 tt 步前的状态,ata_t 表示助手的动作,utu_t 表示这一步期间用户完成的操作。环境按照规则 TT 更新:

st+1=T(st,at,ut) s_{t+1}=T(s_t,a_t,u_t)

这里先用确定性的模拟规则说明问题。真实设备还可能出现随机故障,需要把 TT 换成描述各种下一状态概率的转移模型。

工具通常只返回状态的一部分。记返回的观察为 ot+1o_{t+1},历史为 hth_t,就有:

ot+1=O(st+1,at)ht+1=(ht,at,ot+1) \begin{aligned} o_{t+1}&=O(s_{t+1},a_t)\\ h_{t+1}&=(h_t,a_t,o_{t+1}) \end{aligned}

OO 是观察规则。例如,查远端设置能够看到服务和队列,却不能直接看见用户手里的纸。助手只能根据手头的历史选择下一步。这叫作部分可观察:信息有缺口,行动既能改变环境,也能帮助补上缺口。

这也是 ReAct 关注的连接:模型交替进行语言层面的分析和实际行动,让外部观察参与后续决策。原论文研究了问答与交互任务,不能把“用了一个循环”就称为完整复现。下面的小模型只选动作字母,没有生成自由形式的分析文本。ReAct 原文第 2 节

2. 搭一台会认真记账的打印机

五个条件,一页也不能多

我们的环境有五个布尔值:远端服务已启用、队列已清空、设备已开机、有纸、线已接好。全部为真时,打印动作才能成功。

远端服务和队列可以由助手直接修改;开机、装纸、接线必须请求模拟用户完成。这个“用户”是一段固定规则程序,会立即完成、延迟完成或拒绝指定操作,并不是另一个语言模型,也不模拟真实人的表达习惯。

可选动作只有十一种。字母是代码中的动作编号,不需要把它们记成缩写。

动作含义直接结果
A查看远端返回服务与队列状态
B询问设备情况返回开机、纸张与接线状态
C、D启用服务、清空队列立即修改相应远端状态
E、F、G请求开机、装纸、接线已完成、已受理、等待中或被拒绝
H打印一页实际页数加一,或返回未就绪
I结束任务停止交互,交给独立规则判断成功
J等待让交互时钟前进一步
K转交处理在必要操作被拒绝后结束未解决的任务

每个动作都会让时钟前进一步。一步开始时,先兑现已经到期的用户操作,再执行当前动作。这里的“一步”是模拟时序,不是一秒钟。

环境不会替模型屏蔽不合时宜的动作。缺纸时仍可以选 H,打印两页后仍可以选 I;这样才能看见真实错误,而不是由动作菜单悄悄替模型做好决定。

正常任务的通过条件是:

通过=(已结束) ∧ (实际打印页数=1) \text{通过}= (\text{已结束})\ \land\ (\text{实际打印页数}=1)

∧\land 表示“并且”。如果用户确实无法完成必要操作,则改为要求:该操作已经被拒绝、没有打印、最终选择转交。直接转交一个本可完成的任务,或者一句“完成了”之后没有实际打印,都不通过。

这套规则刻意把“结束交互”和“完成目标”分开。它也很有限:不检查纸上内容或打印质量,不能当成真实打印设备的验收系统。

先固定八种起点

实验开始前,我们写下八个案例,随后全部运行。没有看完结果再留下容易成功的起点。

案例开始时的问题用户响应
ready全部就绪即时
server服务关闭、队列未清空即时
paper缺纸即时
device未开机、未接线即时
mixed服务关闭、缺纸延迟 2 步
all-now五项均未就绪即时
all-delayed五项均未就绪延迟 3 步
paper-unavailable缺纸无法装纸,拒绝请求

案例名和初始状态只交给环境与评分程序,不放进模型提示。所有案例给助手的目标都一样:打印恰好一页并结束;必要用户操作被拒绝时才转交。

一次正确的等待

先运行一份手写策略:查看、修复、等待完成回执、打印、结束。它只使用已经收到的观察,不偷看隐藏状态。它的作用是检查任务是否可完成,以及时间规则是否符合预期;8/8 是这段程序的结果,不是小模型的成绩。

以 mixed 为例,第 4 步请求装纸,环境返回 accepted,预约在时钟 6 完成。第 5 步等待时纸还没装好,第 6 步才收到完成事件。接着打印,最后结束。

图 1:从程序实际执行记录绘制。已受理只表示操作进入等待队列;完成事件才把“有纸”改为真。

这份策略在八个案例中分别用了 4、6、5、6、8、9、12、4 步,共 54 步。最后一个案例通过的是“确认无法装纸后转交”,并没有打印成功。

3. 让真实模型来选择下一步

这次到底运行了什么

模型使用 Qwen2.5-0.5B-Instruct,固定修订为 7ae557604adf67be50417f59c2c2f167def9a775。在 CPU 上用 float32、4 线程、eager attention 运行,Python 3.12.12、PyTorch 2.14.1、Transformers 4.57.3。没有训练或更新权重。

提示和工具说明使用英文;本页用中文解释其含义,实验不评价中文指令能力。每次输入包括动作说明、任务目标和完整可见历史。程序计算模型对下一个词元的分数,只在 A~K 这十一种单词元字母中选最高分,不采样。设动作 aa 的分数为 za(ht)z_a(h_t),则:

at=arg⁡max⁡a∈{A,…,K} za(ht) a_t=\underset{a\in\{A,\ldots,K\}}{\arg\max}\ z_a(h_t)

arg⁡max⁡\arg\max 的意思是“取使分数最大的那个动作”。这里每一步确实经过了语言模型前向计算,但输出接口经过人为简化:不是自由生成 JSON,也不是模型原生的工具调用接口。

每个案例最多执行 16 个动作,输入加本次输出上限为 4096 词元;若超出上限就停止,不截断历史。本次实际输入为 276~514 词元,没有触及上下文上限。

我们固定比较两种反馈:

模式模型收到什么
full时钟、状态码、具体状态字段、用户完成事件
receipt时钟与状态码,隐藏具体字段和完成事件

receipt 仍能收到 printed、refused 等状态,不能称为“没有反馈”。它改变了信息的详细程度;两种模式的提示也各有一句相应说明。因此,这是两个完整交互配置的对照。

200 次选择,没有一张测试页

运行结果如下:

方案通过案例总动作数
手写观察策略,full8/854
小模型,full0/872
小模型,receipt0/8128

full 的八个案例都走出了相同序列:连续七次 C,然后 J,最后 K。也就是不断启用服务,等一步,再转交。它没有观察纸张、队列或接线,没有请求用户操作,自然也没有依据认定用户无法配合。

receipt 的八个案例同样走出相同序列:C、A、八次 C、六次 J,最后耗尽 16 步。它查过一次远端,但只得到简短状态码,随后又回到启用与等待。

图 2:每组中的八个案例动作完全一致。方框宽度用于排版,不表示耗时;次数写在字母旁。

为什么不同初始状态会产生一样的路径?因为它们还没被模型区分出来。反复执行 C,只能反复确认服务已启用;查不到纸张,也修不好接线。在这些轨迹中,两种反馈模式各自返回的可见内容始终没有区分八个案例,确定性选择便沿着同一路径继续。

甚至 ready 已经全部就绪,模型也没选 H。于是,这批失败发生在最基本的行动推进上,还没有走到复杂的延迟协作。我们不能根据两个 0/8 就说“详细反馈没用”,也不能把这个小模型的结果推广到所有 Agent。

记录还保存了十一种合法字母在原始词表分布中的总概率,实际为约 99.35%~99.69%。这说明本次失败并非主要表现为“模型几乎只想输出菜单外的文字”。但合法动作的概率高,完全不代表动作正确。第一步最偏好的 C,在十一项中的归一化概率也只有约 16.8%。

这些是八个固定案例、每种配置各一次贪心运行,不是八次独立抽取的真实用户任务。失败过程值得分析,数字的适用范围也就到这里。

4. 任务长了,为什么容易越走越偏

十六步并不算真正的长期任务。但这台小打印机已经展示了两个会被长任务放大的问题:旧观察不断重复,目标却没有推进;“应该做什么”与“已经做到什么”混在了一起。

假设一个任务有 LL 个关键环节。在前面都成功的条件下,每个环节的成功概率都恰好是 qq,而且任何一次失败都无法挽回,那么整条链成功的概率是:

P(全部成功)=qL P(\text{全部成功})=q^L

若 q=0.95q=0.95,十个环节约为 59.9%,二十个环节约为 35.8%。这是说明累积风险的算例,不是从本书轨迹估计的成功率。若只知道每一步单独看起来“95% 正确”,还需要独立性等额外条件,才能直接相乘。

真实任务往往允许重试、撤销或换方案,因此不必接受“一错到底”。更有用的计划会把阶段结束的证据写清楚:

阶段怎样知道已经完成
检查条件已取得当前远端与设备状态
修复条件所需变更已完成,而不只是已受理
执行目标实际打印计数变成 1
结束已确认页数,选择结束动作

如果任务恢复时只保存了“接下来打印”,却丢了“装纸请求尚在等待中”,下一次行动就可能建立在错误前提上。可以保留当前目标、已核实状态、未完成操作及其时间,再在关键写入之前重新读取会变化的状态。长上下文与记忆中的事件时间与信息更新,在这里直接影响行动。

检查也要花成本。每做一步就把所有状态全查一遍,可能浪费大量调用;一直相信开头那次查询,又可能使用过时信息。更合理的检查位置取决于哪些信息容易变化,以及弄错之后需要付出多少代价。

还有一个容易误判的动作:等待。图 1 中的等待有明确对象——一个尚未完成的装纸请求;模型轨迹中的连续等待却没有任何待完成用户操作。在后一种情况下,延长步数上限未必能带来进展。系统可以记录连续无变化的动作,在达到约定阈值后重新规划或停止,再把这一规则本身纳入评估。

交互接口也会影响这种行为。SWE-agent 研究了文件查看、搜索、编辑反馈和历史管理:让模型看到相关片段、编辑后的状态以及错误,比把未经整理的全部终端内容塞进去更值得认真设计。其接口消融在特定模型和 SWE-bench Lite 设置下进行,不意味着存在适合所有工具的统一历史长度。SWE-agent 原文第 3 节与第 5.1 节

5. 多一个执行者,多了哪些事情

能控制的部分不同,沟通才有实际内容

打印助手能修改远端配置,用户能装纸、接线。两者都在改变同一个任务,却看到不同信息、拥有不同操作。只要把用户当成“提供一句回复的人”,就很容易漏掉正在发生的状态变化。

τ²-Bench 把这一点做成了可执行的双控制环境:Agent 和模拟用户各有工具,用户行为与环境状态相连。论文比较了正常协作、由 Agent 直接控制全部工具,以及提供正确操作计划等条件,用来拆分行动与沟通困难。后两种给了额外能力或信息,是诊断用的对照,不能直接当成真实客服的部署方案。τ²-Bench 原文第 3、4 节

我们的打印环境只保留了最小的权限分工和延迟规则,没有自然语言协商、歧义、情绪或复杂用户行为。它能检验“已受理之后何时改变状态”,不能检验“这句话是否能让真实用户理解并完成操作”。

两个人都看见空位,为什么会超额

再看另一个小实验:某个时段只有一个预约名额,执行者 A 和 B 都想预约。每个人先读一次,再写一次。

如果双方都在任何写入之前读到“空闲”,之后只相信这个旧读数,就可能各自写入成功,最后出现两个预约。两个人在各自的局部视角里都觉得操作合理,共享状态却已经违反容量限制。

一种办法是在读取时同时取得版本号。提交时,把“检查版本仍相同”和“写入并增加版本”放在一个不可分割的操作中:

vread=vcurrent⟹允许提交vcurrent←vcurrent+1 \begin{aligned} v_{\text{read}}&=v_{\text{current}} \quad\Longrightarrow\quad \text{允许提交}\\ v_{\text{current}}&\leftarrow v_{\text{current}}+1 \end{aligned}

vv 是状态版本,不是预约数量。假设双方都读到版本 0,A 成功提交后版本变成 1,B 再带着 0 提交就会得到冲突。B 需要重新读取或另选时段。

图 3:程序中“检查并写入”是不可分割的一步。若检查和写入本身还能被别人的操作插开,单独比较版本号仍不足以保护名额。

四个操作总共有 4!=244!=24 种排列。保留每个人“先读后写”的次序后,有六种合法交错。程序把它们全部枚举:

提交规则超额的交错数
仅相信自己先前读到的空闲状态4/6
提交时原子检查版本0/6

这里的 4/6 是六种顺序中有四种出错,不是测得真实系统有三分之二概率出错。实际调度并不一定等概率;本实验也没有启动并行线程或让两个语言模型对话。

原子检查保证容量没有被突破,却没有让两个任务都成功。拒绝之后如何重新协商、谁优先、能否换时间,仍需要工作流或 Agent 决策。把一致性约束交给环境,并不会自动解决业务目标之间的冲突。

合作也可能伴随竞争

如果两个 Agent 为不同的人服务,目标未必一致。一个想买得便宜,另一个想卖得贵;“彼此交流更多”也不能直接等同于“共同完成同一个任务”。

2026 年的 CoffeeBench 让两家农场、两家烘焙商和两家零售商经营 90 个模拟日。被评模型控制其中一家烘焙商,其他企业由固定参考 Agent 控制;交易、库存和现金将行动串成长期后果。论文还记录了计划看似连贯、行动却逐渐变成等待的现象。这与我们关注的停滞问题相通,但环境和证据完全不同:作者每个模型仅运行三次,模拟经济也不能代表真实经营。CoffeeBench 原文第 3、5、7 节

6. 怎样评价“真的做成了”

最终状态之外,还看过程

只读最后一句话,很容易把 I 对应的“结束”看成成功。只检查“曾经打印过”,又会把重复打印两页放过去。评价应回到任务约定的状态与行为。

在这个环境里,至少要区分:最后是否满足目标、有没有产生多余页数、是否在被拒绝前擅自转交,以及用了多少动作。动作少只有在任务确实完成时才有意义;九步转交失败,不比十二步正确完成更高效。

评价程序本身也会出错。可以先用明显的正反例检查它:初始状态不能通过;直接结束不能通过;打印一页但不结束不能通过;打印两页再结束不能通过;正常一页再结束才通过。每次新案例还要重置状态,避免前一项已经装好的纸悄悄帮助下一项。

一次成功,和反复可靠地成功

“多试几次,总有一次能做成”和“每次交给它都稳妥”是不同问题。

假设同一个任务运行 nn 次,其中 cc 次成功,再从这些记录中均匀选出 kk 次,不放回。所选的 kk 次全部成功的比例为:

pass⁡k=(ck)(nk) \operatorname{pass}^{k} =\frac{\binom{c}{k}}{\binom{n}{k}}

(nk)\binom{n}{k} 是从 nn 个对象中挑 kk 个、不考虑顺序的组合数。相反,所选尝试中至少一次成功的比例为:

pass@⁡k=1−(n−ck)(nk) \operatorname{pass@}k =1-\frac{\binom{n-c}{k}}{\binom{n}{k}}

第二式用“全部选中失败”的反面来计算。当可选数量少于 kk 时,相应组合数取 0;这里要求 1≤k≤n1\leq k\leq n。若把这些比例用作新运行的估计,还要考虑重复实验是否来自相同条件,以及样本量是否足够。

例如,五次中四次成功,选两次:总共十对,其中六对全成功,却每一对都至少有一次成功。因此:

pass⁡2=610=0.6,pass@⁡2=1 \operatorname{pass}^{2}=\frac{6}{10}=0.6, \qquad \operatorname{pass@}2=1

τ²-Bench 使用重复运行考察可靠性;阅读时要留意上标 kk 与 @ 符号的区别。我们的八个起点是八个不同案例,各配置没有重复采样,不能拿它们冒充同一任务的重复成功率。τ²-Bench 原文第 4 节

环境也能提供训练经验

如果把交互轨迹、任务结果和奖励用于更新模型参数,环境就不只是考场,也成了练习场。但一次工具返回被追加进上下文,并不等于模型权重完成了一次训练;本页所有模型运行都保持权重不变。

2026 年 10 月的 MAI-Gym 预印本把 GUI 任务组织成环境、初始状态、目标与验证器,并让执行流程连接评测、数据与强化学习。其任务生成流程会检查初始未完成、参考状态完成、重置后未完成,再检验任务是否能通过实际界面操作到达。直接改数据库构造完成状态,可帮助检查验证器,却不能替代 Agent 使用界面的能力。MAI-Gym 原文第 2、3 节

模拟环境里的进步,还要走过迁移这一步。该论文在 52 个配对购票任务上报告:仅在克隆应用中进行强化学习后,真实手机应用成功率从 28.8% 升到 48.1%;训练后的克隆应用成绩为 73.1%。改善确实存在,两种环境仍有明显差距。这是作者在特定应用上的报告,本书没有运行其训练,也不能据此认定所有模拟经验都会迁移。MAI-Gym 原文第 4.3.4 节

7. 把实验跑起来

先运行不需要模型的部分

在仓库根目录运行:

python3 src/examples/agent_environment.py

程序读取随书保存的记录,输出:

手写观察策略:8/8
小模型 full:0/8,共 72 次动作选择
小模型 receipt:0/8,共 128 次动作选择
名额争用 stale_check:4/6 种交错超额
名额争用 revision_check:0/6 种交错超额

也可以下载环境与汇总脚本,在它旁边建立 data 文件夹,放入固定方案、参考策略与争用记录和完整模型记录。

默认命令只汇总已有记录。要重新执行手写策略与全部交错,运行:

python3 src/examples/agent_environment.py --controls agent-environment-controls-new.json

这部分只使用 Python 标准库。输出文件不能已存在。先读 Printer.step 看环境怎样变化,再读 reference_episode 看策略怎样根据观察行动,最后读 contention 看六种交错怎样枚举。

展开完整环境与汇总代码
"""Agent 与环境交互:可重放的打印机环境、观察基线与资源争用。"""

from argparse import ArgumentParser
from copy import deepcopy
from hashlib import sha256
from itertools import permutations
import json
from pathlib import Path

DATA = Path(__file__).with_name("data")
PLAN = DATA / "agent-environment-plan.json"
RECORD = DATA / "agent-environment-model.json"
CONTROL = DATA / "agent-environment-control.json"
FIELDS = ["enabled", "queue_clear", "power", "paper", "cable"]
ACTIONS = {
    "A": "inspect_server: read enabled and queue_clear",
    "B": "ask_device_status: read power, paper and cable from the simulated user",
    "C": "enable_server: set enabled=true",
    "D": "clear_queue: set queue_clear=true",
    "E": "ask_power_on: request the user to set power=true",
    "F": "ask_load_paper: request the user to set paper=true",
    "G": "ask_connect_cable: request the user to set cable=true",
    "H": "print_page: print one page only when all five readiness fields are true",
    "I": "finish: end the task as completed",
    "J": "wait: let one interaction tick pass",
    "K": "handoff: end unresolved after a required user operation was refused",
}
FIXES = {"enabled": "C", "queue_clear": "D", "power": "E", "paper": "F", "cable": "G"}


def digest(path):
    return sha256(Path(path).read_bytes()).hexdigest()


def fingerprint(value):
    return sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
                             separators=(",", ":")).encode()).hexdigest()


def save_new(path, value):
    with Path(path).open("x") as stream:
        json.dump(value, stream, ensure_ascii=False, indent=2)
        stream.write("\n")


def make_plan():
    specifications = [
        ("ready", [], 0, None),
        ("server", ["enabled", "queue_clear"], 0, None),
        ("paper", ["paper"], 0, None),
        ("device", ["power", "cable"], 0, None),
        ("mixed", ["enabled", "paper"], 2, None),
        ("all-now", FIELDS, 0, None),
        ("all-delayed", FIELDS, 3, None),
        ("paper-unavailable", ["paper"], 0, "paper"),
    ]
    return dict(
        version=1, date="2026-10-20", model="Qwen/Qwen2.5-0.5B-Instruct",
        revision="7ae557604adf67be50417f59c2c2f167def9a775",
        settings=dict(threads=4, seed=2026, dtype="float32", attention="eager",
                      max_steps=16, input_plus_output_cap=4096,
                      selection="argmax next-token logit restricted to A..K; no sampling"),
        feedback=["full", "receipt"],
        cases=[dict(id=name, initial={key: key not in faults for key in FIELDS},
                    user_delay=delay, unavailable=unavailable)
               for name, faults, delay, unavailable in specifications],
        selection="All fixed cases and both feedback modes; no result-based prompt selection",
    )


class Printer:
    def __init__(self, case):
        self.case = deepcopy(case)
        self.state = deepcopy(case["initial"])
        self.clock, self.printed = 0, 0
        self.pending, self.denied = {}, []
        self.terminal = None

    def snapshot(self):
        return dict(clock=self.clock, readiness=deepcopy(self.state),
                    pending=deepcopy(self.pending), denied=self.denied[:],
                    printed=self.printed, terminal=self.terminal)

    def step(self, action):
        if action not in ACTIONS or self.terminal is not None:
            raise ValueError("动作不合法,或任务已结束")
        self.clock += 1
        events = []
        for key, due in list(self.pending.items()):
            if due <= self.clock:
                self.state[key] = True
                del self.pending[key]
                events.append({"user_completed": key})
        status, details = "ok", {}
        if action == "A":
            details = {key: self.state[key] for key in FIELDS[:2]}
        elif action == "B":
            details = {key: self.state[key] for key in FIELDS[2:]}
        elif action in {"C", "D"}:
            key = "enabled" if action == "C" else "queue_clear"
            self.state[key] = True
            details = {key: True}
        elif action in {"E", "F", "G"}:
            key = {"E": "power", "F": "paper", "G": "cable"}[action]
            if self.state[key]:
                status = "already_ready"
            elif self.case["unavailable"] == key:
                status = "refused"
                details = {"required_operation_unavailable": key}
                if key not in self.denied:
                    self.denied.append(key)
            elif key in self.pending:
                status, details = "pending", {"due": self.pending[key]}
            elif self.case["user_delay"] == 0:
                self.state[key] = True
                status, details = "completed", {key: True}
            else:
                self.pending[key] = self.clock+self.case["user_delay"]
                status, details = "accepted", {"due": self.pending[key]}
        elif action == "H":
            missing = [key for key in FIELDS if not self.state[key]]
            if missing:
                status, details = "not_ready", {"missing": missing}
            else:
                self.printed += 1
                status, details = "printed", {"total_pages": self.printed}
        elif action == "I":
            self.terminal = "finished"
            status = "finished"
        elif action == "K":
            self.terminal = "handoff"
            status = "handoff"
        return dict(clock=self.clock, status=status, details=details, events=events)

    def grade(self):
        if self.case["unavailable"]:
            success = (self.terminal == "handoff" and self.printed == 0
                       and self.case["unavailable"] in self.denied)
        else:
            success = self.terminal == "finished" and self.printed == 1
        return dict(success=success, printed=self.printed, terminal=self.terminal,
                    steps=self.clock, unresolved_fields=[k for k in FIELDS if not self.state[k]])


def visible(result, mode):
    if mode == "full":
        return deepcopy(result)
    if mode == "receipt":
        return {key: result[key] for key in ["clock", "status"]}
    raise ValueError(mode)


def messages(history, mode):
    menu = "\n".join(f"{key}: {value}" for key, value in ACTIONS.items())
    instruction = (
        "You operate a simulated printer. Print exactly ONE test page, then finish. "
        "Initial readiness is unknown. Tools are the only way to observe or change it. "
        "Physical changes require the simulated user; accepted means requested, not completed. "
        "User operations may take several interaction ticks. Every action advances one tick. "
        "Inspect, fix the missing conditions, verify feedback, and stop when done. "
        "If a required user operation is refused, hand off instead of declaring success. "
        "Choose exactly one letter A through K for the NEXT action. Do not explain.\n"+menu)
    note = ("Tool feedback includes details and completed user events." if mode == "full" else
            "Tool feedback gives only the receipt status; details and user events are hidden.")
    return [
        {"role": "system", "content": instruction},
        {"role": "user", "content": note+"\nHistory:\n"+
         json.dumps(history, separators=(",", ":"))+"\nNext action letter:"},
    ]


def reference_episode(case, max_steps):
    """手写观察策略;只根据已收到的回执作选择,不直接读取隐藏就绪状态。"""
    env, knowledge, history, trace = Printer(case), {}, [], []
    while not env.terminal and env.clock < max_steps:
        if any(row["observation"]["status"] == "refused" for row in history):
            action = "K"
        elif any(row["observation"]["status"] == "printed" for row in history):
            action = "I"
        elif "enabled" not in knowledge:
            action = "A"
        elif any(not knowledge[key] for key in FIELDS[:2]):
            action = FIXES[next(k for k in FIELDS[:2] if not knowledge[k])]
        elif "power" not in knowledge:
            action = "B"
        elif any(not knowledge[key] for key in FIELDS[2:]):
            waiting = {row["action"] for row in history
                       if row["observation"]["status"] in {"accepted", "pending"}}
            remaining = [k for k in FIELDS[2:] if not knowledge[k] and FIXES[k] not in waiting]
            action = FIXES[remaining[0]] if remaining else "J"
        else:
            action = "H"
        before = env.snapshot()
        result = env.step(action)
        for key, value in result["details"].items():
            if key in FIELDS:
                knowledge[key] = value
        for event in result["events"]:
            knowledge[event["user_completed"]] = True
        history.append({"action": action, "observation": result})
        trace.append(dict(action=action, before=before, result=result, after=env.snapshot()))
    return dict(id=case["id"], trace=trace, **env.grade())


def contention():
    """枚举 A、B 各先读后写的全部交错;提交在本函数中是不可分割的一步。"""
    orders = [list(order) for order in permutations(["A_read", "A_write", "B_read", "B_write"])
              if order.index("A_read") < order.index("A_write")
              and order.index("B_read") < order.index("B_write")]
    rows = []
    for mode in ["stale_check", "revision_check"]:
        for order in orders:
            bookings, revision, seen, events = [], 0, {}, []
            for operation in order:
                actor, operation_type = operation.split("_")
                if operation_type == "read":
                    seen[actor] = dict(free=not bookings, revision=revision)
                    result = deepcopy(seen[actor])
                elif not seen[actor]["free"]:
                    result = "already_busy"
                elif mode == "revision_check" and seen[actor]["revision"] != revision:
                    result = "conflict"
                else:
                    bookings.append(actor)
                    revision += 1
                    result = "reserved"
                events.append(dict(actor=actor, operation=operation_type, result=result,
                                   bookings=bookings[:], revision=revision))
            rows.append(dict(mode=mode, order=order, events=events, bookings=bookings,
                             overbooked=len(bookings) > 1))
    return rows


def control_report(plan):
    return dict(source_sha256=digest(__file__), plan_sha256=fingerprint(plan),
                reference=[reference_episode(case, plan["settings"]["max_steps"])
                           for case in plan["cases"]], contention=contention())


def main():
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--controls", type=Path)
    args = parser.parse_args()
    if args.controls:
        save_new(args.controls, control_report(make_plan()))
        print(f"已保存观察策略与全部交错顺序:{args.controls}")
        return
    plan = json.loads(PLAN.read_text())
    control = json.loads(CONTROL.read_text())
    record = json.loads(RECORD.read_text())
    assert record["complete"]
    assert record["plan_sha256"] == control["plan_sha256"] == fingerprint(plan)
    n = len(plan["cases"])
    print(f"手写观察策略:{sum(r['success'] for r in control['reference'])}/{n}")
    for mode in plan["feedback"]:
        rows = [r for r in record["rows"] if r["mode"] == mode]
        print(f"小模型 {mode}:{sum(r['grade']['success'] for r in rows)}/{n},"
              f"共 {sum(len(r['trace']) for r in rows)} 次动作选择")
    for mode in ["stale_check", "revision_check"]:
        rows = [r for r in control["contention"] if r["mode"] == mode]
        print(f"名额争用 {mode}:{sum(r['overbooked'] for r in rows)}/{len(rows)} 种交错超额")


if __name__ == "__main__":
    main()

让本地小模型重新选择动作

模型脚本默认同样不下载或加载权重:

python3 src/examples/agent_environment_models.py

输出:

默认不加载模型。使用 --run --out-dir 新目录,才运行 16 条交互轨迹。

显式重跑时,在仓库根目录使用固定依赖:

uv run --python 3.12 --with-requirements src/examples/requirements-inference.txt \
  python src/examples/agent_environment_models.py \
  --run --out-dir runs/agent-environment-new

首次运行需要联网取得固定模型;已有缓存时可以复用。float32 权重与运行缓冲需要数 GB 内存。本书这次 200 次前向计算及相邻步骤记录的计时合计约 86.7 秒,加载、文件哈希与逐步 JSON 保存另计,不能把它当成所有设备上的耗时承诺。

输出目录必须是新目录。程序先保存方案,再加载模型;每一步保存完整提示、词元 ID、十一项分数、实际环境结果、可见反馈和状态快照,同时记录代码、模型文件与依赖版本。状态快照用于事后复核,没有送进模型输入。

如果修改提示、模型或工具说明,请给新实验另起目录,并保留全部案例。可以观察它是否开始主动查询、是否真的打印、是否会重复打印;不要只看总体成功数是否上升。

展开完整模型运行代码
"""显式 --run 才加载固定小模型,在真实执行的教学环境中选择动作。"""

from argparse import ArgumentParser
from datetime import datetime, timezone
from importlib.metadata import version
import json
from pathlib import Path
import platform
import time

import agent_environment as lab


def experiment(directory):
    directory.mkdir(parents=True, exist_ok=False)
    plan = lab.make_plan()
    lab.save_new(directory/"plan.json", plan)  # 在加载模型、观察结果前固定。
    lab.save_new(directory/"control.json", lab.control_report(plan))
    import torch
    from huggingface_hub import snapshot_download
    from transformers import AutoModelForCausalLM, AutoTokenizer
    settings = plan["settings"]
    torch.set_num_threads(settings["threads"])
    torch.manual_seed(settings["seed"])
    snapshot = Path(snapshot_download(plan["model"], revision=plan["revision"],
                                    allow_patterns=["*.json", "*.safetensors", "merges.txt",
                                                    "README.md", "LICENSE"]))
    tokenizer = AutoTokenizer.from_pretrained(snapshot, trust_remote_code=False)
    letters = list(lab.ACTIONS)
    ids = [tokenizer.encode(letter, add_special_tokens=False) for letter in letters]
    assert all(len(row) == 1 for row in ids)
    token_ids = [row[0] for row in ids]
    assert len(set(token_ids)) == len(letters)
    model = AutoModelForCausalLM.from_pretrained(
        snapshot, dtype=torch.float32, attn_implementation=settings["attention"],
        trust_remote_code=False).eval()
    report = dict(
        complete=False, started_at=datetime.now(timezone.utc).isoformat(),
        model=plan["model"], revision=plan["revision"], settings=settings,
        plan_sha256=lab.fingerprint(plan), action_tokens=dict(zip(letters, token_ids)),
        source_sha256={p.name: lab.digest(p) for p in [Path(__file__), Path(lab.__file__)]},
        model_files_sha256={p.name: lab.digest(p) for p in snapshot.iterdir()
                            if p.is_file() and (p.suffix in {".json", ".safetensors"}
                                               or p.name == "merges.txt")},
        environment=dict(python=platform.python_version(), platform=platform.platform(),
                         packages={name: version(name) for name in
                                   ["torch", "transformers", "huggingface-hub",
                                    "tokenizers", "safetensors"]}),
        rows=[],
    )

    def checkpoint():
        temporary = directory/"model.pending.json"
        temporary.write_text(json.dumps(report, ensure_ascii=False, indent=2)+"\n")
        temporary.replace(directory/"model.json")

    checkpoint()
    for mode in plan["feedback"]:
        for case in plan["cases"]:
            env, history = lab.Printer(case), []
            row = dict(id=case["id"], mode=mode, trace=[], stopped="step_limit")
            report["rows"].append(row)
            for _ in range(settings["max_steps"]):
                conversation = lab.messages(history, mode)
                inputs = tokenizer.apply_chat_template(
                    conversation, tokenize=True, add_generation_prompt=True,
                    return_dict=True, return_tensors="pt")
                length = inputs["input_ids"].shape[1]
                if length+1 > settings["input_plus_output_cap"]:
                    row["stopped"] = "context_budget"
                    break
                started = time.perf_counter()
                with torch.inference_mode():
                    scores = model(**inputs).logits[0, -1]
                selected = scores[token_ids]
                probabilities = selected.softmax(0).tolist()
                action = letters[int(selected.argmax())]
                before = env.snapshot()
                result = env.step(action)
                observation = lab.visible(result, mode)
                trace = dict(
                    messages=conversation, input_ids=inputs["input_ids"][0].tolist(),
                    input_tokens=length, action=action, action_token=token_ids[letters.index(action)],
                    logits=selected.tolist(), conditional_probabilities=probabilities,
                    legal_token_mass=scores.softmax(0)[token_ids].sum().item(),
                    seconds=time.perf_counter()-started, before=before,
                    result=result, observation=observation, after=env.snapshot())
                row["trace"].append(trace)
                history.append(dict(action=action, observation=observation))
                row["grade"] = env.grade()
                checkpoint()
                if env.terminal:
                    row["stopped"] = env.terminal
                    break
            row["grade"] = env.grade()
            checkpoint()
            print(f"{mode}/{case['id']}: success={row['grade']['success']} "
                  f"steps={len(row['trace'])} actions="
                  f"{''.join(step['action'] for step in row['trace'])}", flush=True)
    report["complete"] = True
    report["finished_at"] = datetime.now(timezone.utc).isoformat()
    checkpoint()
    print(f"完成 16 条真实模型交互轨迹:{directory}", flush=True)


def main():
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--run", action="store_true")
    parser.add_argument("--out-dir", type=Path)
    args = parser.parse_args()
    if not args.run:
        print("默认不加载模型。使用 --run --out-dir 新目录,才运行 16 条交互轨迹。")
        return
    if args.out_dir is None:
        parser.error("需要尚不存在的 --out-dir")
    experiment(args.out_dir)


if __name__ == "__main__":
    main()

也可下载模型运行脚本,与环境脚本放在同一目录。依赖与安装方式可回查工具附录。

8. 停下来试一试

  1. 只有纸张未就绪,用户操作延迟两步。第 1 步请求装纸,第 2、3 步分别打印。哪次能成功?为什么?
  2. A 返回服务已启用、队列已清空。是否足以认定可以打印?下一次查询可以补上什么信息?
  3. 已经打印一页却没有结束,为什么不满足本实验目标?打印两页再结束,又为什么失败?
  4. 每个关键环节在前面都成功时的成功概率为 0.95。计算十步与二十步全部成功的概率,并说出这个算例省略了哪些恢复机制。
  5. 给缺纸案例另写一份策略,先 A、B,再请求装纸,观察完成后打印并结束。把延迟改成 3,它是否仍能完成?不要直接读取 env.state 来选择动作。
  6. 列出两个执行者各自先读后写的六种交错。哪两种不会让“仅相信旧读数”的规则超额?
  7. 若版本检查和实际写入是两个独立步骤,两个执行者能否都先通过版本 0 的检查?这说明图 3 依赖什么假设?
  8. 同一任务五次中四次成功,抽两次时,全部成功与至少一次成功分别是多少?如果不知道哪次成功,“至少一次”是否自动意味着能选出正确结果?
  9. full 与 receipt 都得到 0/8,能否判断删掉反馈细节不会影响 Agent?请结合实际动作序列回答。
  10. 把一次失败轨迹追加到下一轮提示,与用失败轨迹计算训练损失并更新参数,有什么区别?两种方法都在哪些地方可能过度适应这八个案例?
参考答案与实验提示
  1. 第 1 步将完成时钟设为 3;第 2 步仍缺纸,打印失败。第 3 步先兑现装纸事件,再执行打印,所以这次成功。最终还需要结束动作。
  2. 不足以;开机、纸张和接线仍未知。B 能返回这三项设备状态。询问完成后,真实系统里还要考虑状态是否又发生变化。
  3. 目标同时要求恰好一页与明确结束。前者少了结束,后者多打印了一页;“曾经做对过一步”不是最终目标。
  4. 分别约为 0.599 与 0.358。算例没有重试、回滚、检查后改正或替代路径,不能直接预测真实系统可靠性。
  5. 可以参照 reference_episode,根据回执和完成事件维护已知状态。迟迟没有事件时不能自行把纸张设为就绪;等待仍计入 16 步预算。另试用户拒绝装纸,应转交而非继续等待。
  6. 用 Ar、Aw、Br、Bw 表示读写,六种依次为:Ar Aw Br Bw、Ar Br Aw Bw、Ar Br Bw Aw、Br Bw Ar Aw、Br Ar Bw Aw、Br Ar Aw Bw。第一与第四种不会超额,因为后一人读时已经占用。
  7. 可以。两人都先读到 0、检查通过,再各自写入。检查与写入必须作为原子操作执行,或有提供同等约束的事务机制;版本号本身不是锁。
  8. 全部成功为 0.6,至少一次为 1。后者只说明候选中存在成功尝试,还需要能识别并选择它;参见推理与测试时计算。
  9. 不能。模型没有进入打印与延迟用户操作的关键阶段,同一模式下也没有获取足以区分案例的信息。需要更多能使用工具的配置、独立案例及重复实验,再判断反馈的影响。
  10. 追加历史改变本次输入,训练改变参数。反复按这八题修改提示,或只用它们更新权重,都可能记住局部规律;应另设开发与留出任务,测试不同故障组合、延迟和用户行为。

9. 沿着交互过程继续读

可以把论文放在同一条问题线上:模型看到什么,能做什么,别人怎样参与,最后怎样确认完成。

  1. 分析怎样与行动相接? Yao 等,ReAct: Synergizing Reasoning and Acting in Language Models,v3,2023-03-10。阅读第 2 节的交互形式,再比较语言分析与环境动作的不同作用。
  2. 工具接口会怎样影响模型行为? Yang 等,SWE-agent: Agent-Computer Interfaces Enable Automated Software Engineering,v3,2024-11-11。对照第 3 节与第 5.1 节的接口设计和消融条件。
  3. 用户也能改变环境时,任务怎样定义? Barres 等,τ²-Bench: Evaluating Conversational Agents in a Dual-Control Environment,v1,2025-06-09。阅读第 3、4 节,再查作者代码中的任务与状态约定。
  4. 多个不同角色怎样长期互动? Sugiura 等,CoffeeBench: Benchmarking Long-Horizon LLM Agents in Heterogeneous Multi-Agent Economies,v1,2026-06-15。先看第 3 节环境与第 7 节局限,可沿作者代码与轨迹入口继续核查。
  5. 如何让环境既能评测,也能训练? Tong 等,MAI-Gym: An Open All-in-One Platform for GUI Agent Evaluation and RL Training across Mobile and Computer Environments,v1,2026-10-14。这是一篇近期预印本。重点看第 2、3 节与第 4.3.4 节的任务验证与模拟到真实的迁移范围。

以后看到一个 Agent 演示,可以沿着它的行动问下去:哪条反馈让它改变了决定?哪个状态证明事情已经完成?如果别人晚了一步,它还能找到接下来的路吗?

回到前沿专题库,下一个问题会从“这次任务能否做完”延伸到“经历许多任务之后,模型怎样学到新东西,又保留已有能力”。