EN

Otter

控制室里的 Claude Code agent:把运行人员的一句话写成调机例程,人批准后由 Badger 执行;已在 LCLS 与 FACET-II 上试过。

最让我不满意的软件是我自己写的。2021 年我创建了 Badger,并主导它到 2025 年,目的是让优化器能在控制室里运行。这一点做到了,但我给它套上的那个窗口需要花时间学:重跑一个存好的例程(routine)只要点一下,而一个新任务即使从模板开始,仍要挑环境(environment),设变量,写目标函数与约束,若再换一种没有用过的算法,它还会带来一堆超参数。因此 Otter 一开始的范围很窄:像对 Claude Code 说话那样说一句「请把 FEL 强度调上去」,由一个智能体(agent)把这句话变成一个例程,你批准,Badger 执行。

后来我给它起名 Otter,取自 OperaTor in TERminal(终端里的操作员),范围也是随这个名字放宽的:控制室里总有一个终端,用 ssh 登进去也有,即使没有桌面环境也是如此;数据和读数据的软件本来就在那里,于是一个新任务多半只是再加一个工具。调机只是我最先想解决的那件事。

2025 年 10 月我画过一个自己的智能体循环,前面是一个分类器,然后是规划器,再往后是各种工具。这个循环从来没有做出来。实际运行的是 Claude Code 本身,加上我为每个任务写的工具与技能(skill),它运行在 Osprey 的安全层之下,Osprey 是 LBNL 开源的框架,专为控制系统里的智能体而写。智能体从不直接向机器写入任何东西。

一个例程需要的信息,大多在运行存档里或者在某个人的脑子里;我当时不知道的是,查找这些信息能不能快到让值班的人愿意等。第一次在真机上试是 2026 年 5 月 14 日在 LCLS 的一次通宵班。LBNL 关于 Osprey 的 IPAC'26 进展文章(我是十七位作者之一)用一段话描述了这次 LCLS 调机。本页的数字来自之后更详细的两次班,6 月在 LCLS,7 月在 FACET-II,两台都是 SLAC 的加速器。

控制室主机上的 Otter

Otter 以一个普通账号运行在控制室的主机上,不用容器。这台主机不联外网,也不能在上面安装任何东西,因此我在集群上把整棵目录树构建好再推过去,代价是失去了可复现性。严格地说,Otter 是 Osprey 在 SLAC 的一套部署(我是 Osprey 的第二大代码贡献者),我在它之上写的是 MCP 服务器和工具,技能,知识包和部署本身。

技能是可以阅读和修改的 Markdown 文档。最小的一个是 /propose-routine,它编写一个例程;/auto-tune 把几个例程串成一轮分阶段运行,在同一个 Badger 窗口里完成;FACET-II 还有 /auto-tune-facet-yaml,它把一整次启动写成 YAML,交给该实验室验证过的运行器(runner)。这些技能调用我写的三个 MCP 服务器,一共几十个工具,它们交回的每一个值都注明出处。一个小的离线知识包补上存档里查不到的东西;当几个来源说法不一时,以实时读数为准,其次是存档,笔记排在末尾。

智能体自己的控制系统接口关掉了写入,因此它不能自己去设任何一个过程变量(Process Variable,PV),但什么都可以读。Shell 和网络访问一律拒绝,任何会改动机器的操作都要先获批准:在 LCLS 是每一次保存和每一次启动,在 FACET-II 则是整条工作流一次批准。通向机器的唯一路径是一次受限的 Badger 运行,起始状态先存下来,运行人员可以保留结果也可以退回;或者是运行器的一步,若失败就恢复它的快照。为了 Otter,我在 Badger 的一个分支上加了一小块远程控制的接口。

架构:运行人员对 Otter(运行在控制室主机上的 Claude Code 智能体)说一句话;Otter 经由一道出错即拒的 PreToolUse 关口调用 MCP 工具;工具读取运行存档,实时读数和一个小知识包,并启动一次受限的 Badger 运行或实验室运行器的一步;只有那次受限运行触及 LCLS 与 FACET-II 的控制系统,运行人员保留或退回结果 值班的运行人员 Otter · Claude Code 运行在控制室主机上 我写的 MCP 工具 Badger 窗口 / 实验室的运行器 LCLS · FACET-II 说一句话, 在关口批准 同一个 Badger 窗口, 同一个停止按钮 普通账号 · 不联外网 只有模型流量出去, 经站内代理 技能是 Markdown: propose-routine · auto-tune auto-tune-facet-yaml Badger 运行存档 实时读数(只读) 起草例程 知识包:6 条笔记, 关键词检索 FACET-II 各阶段 · 验证过的运行器 每个值都注明出处 实时读数 > 过去的运行 > 文档 受限运行: 范围 · 步长上限 起始状态先存下 运行人员保留结果 或退回 EPICS 只有受限运行 触及它 一句话进 · 计划和提问回来 例程进 · 评估回来 设定值 · 读回值 实时读数,只读 · 写入已关闭 工具调用 智能体从不自己写 PV PreToolUse 关口(出错即拒) 写入关闭 · 限值 · 问人
每一次工具调用出去时都要经过一道关口,出错即拒;右边的控制系统只会看到一次受限的 Badger 运行或运行器的一步。

6 月在 LCLS 的班

2026 年 6 月 26 日我们在硬 X 射线线上。十点刚过,运行人员提出要把 FEL 信号调到最大,束损监测器作为软约束,算法由智能体自己选。智能体在大约五百次存档的运行里给旋钮(knob)排序,四块匹配四极铁遥遥领先;它实时读了几个候选的束损监测器,留下真正有读数的那一个(地图与运行人员的笔记对哪个是哪个的说法并不一致)。运行人员批准了三阶段的计划,熟悉的 Badger 窗口就出现在了控制台上。

第一阶段趋于平缓时,智能体请求 Badger 停止,Claude Code 的权限分类器却拒绝了它三次,大约耗掉 28 次评估的束流时间。它没有绕过关口,只是等着并转而问运行人员;运行人员输入「please go ahead and I'll approve」,停止才生效,第二阶段随即在同一个窗口里换上。这一阶段只多了半个百分点,于是智能体自己取消了第三阶段,在 22:27 结束。脉冲能量从 0.468 mJ 升到 1.819 mJ。应当说明,其中大部分是恢复,因为这一轮运行开始时脉冲能量正处于一次下跌之中;结果比运行人员当晚早些时候自己做的分段扫描高出约 4%,而这个差距在逐次运行的涨落范围之内,因此公允的说法是智能体达到了他们的水平,而不是超过了他们。

上下两栏,横轴为 96 次评估。上栏:FEL 脉冲能量从约 0.5 mJ 升到 1.8 mJ,先是 LTUH 段四块匹配四极铁上的 UCB 阶段,再是 LI26 段八块四极铁上的 EI 阶段,虚线为运行人员当晚的最好值,第三阶段空着,智能体停下的位置有标记。下栏:同一段评估里波荡器的束损监测器,始终在软下限之内。
两个阶段在同一个 Badger 窗口里完成(先在四块匹配四极铁上用 UCB,再在八块输运段四极铁上用 EI),第三阶段空着是因为智能体自己取消了它;虚线是运行人员当晚的最好值,下方是束损监测器与它的软下限。

当晚早些时候有一次用极值搜索(extremum seeking)做的精调,存档里几乎没有可以照抄的东西,智能体就按我从文献里提炼的一条笔记来做(文献里给的参数值对我们装的这个优化器是不对的)。那次运行用了一百多次评估达到 1.786 mJ,是那一轮运行之前最好的一次单次运行。整个晚上它编写了十八个例程,每一个都来自一句话的请求。

同样 LTUH 段四块匹配四极铁上的两次单次运行并排,左边是 UCB,右边是极值搜索,上面一行是目标函数,下面一行是每块四极铁相对第一次评估的设定值变化,共用一个标尺。
同样四块匹配四极铁(Q1 到 Q4)上的两次运行,各来自一句请求,左边是 UCB,右边是极值搜索;下面一行是四极铁的动作,贝叶斯优化一步一跳,极值搜索靠抖动探路。

FACET-II 的五次启动

FACET-II 的注入器启动(start-up)是按顺序的六个调机问题,每一个都已经是一个 Xopt 例程,放在一个验证过的运行器后面。例程和运行器都属于 SLAC 的 autonomous-control 程序包,没有人自动化过它们周围的那一部分:每一步之前要有东西检查机器是否就绪,并按机器里实际的束流选参数;每一步之后要读日志并决定下一步怎么办(有时这意味着把人叫醒)。这一层是我做的。

2026 年 7 月 1 日晚运行人员定的规矩是「先把 YAML 给我看」。智能体把每一次启动写成一段短的 YAML 工作流,先给运行人员看,然后一次按键跑完全部六个阶段,中间不需要任何人。五次启动就这样接连跑下来,多数在 1600 pC,智能体在这个电荷量下编的范围原封不动地用到了两次更高电荷量的启动上。运行器本身每次用 16 到 24 分钟,3 小时 18 分钟里其余的时间都花在运行器之外,在步与步之间和启动与启动之间。有一次速调管相位没能稳定下来,智能体读了报错回溯(traceback),判断为瞬态故障,重新提交同一条工作流,一分钟左右就过去了。

图 a:FACET-II 五次启动 A 到 E(电荷量 1600,1600,2000,2200 和 1600 pC)中每个阶段的累计运行器时间,失败的那一步有标记。图 b:每个阶段一个小图(激光指向调节,Schottky 定时,束流轨道校正,能散,注入器发射度,TCAV 调相),每次启动一个箭头,从起始值指向结束值,有测量误差的带误差棒,没有要压低的目标函数的三个阶段为灰色。
五次启动中每个阶段的运行器时间(标记处是失败后重新提交的速调管那一步);右边是每个阶段在每次启动中的起点与终点,灰色表示该阶段没有要压低的目标函数。

每个阶段都完成了,但这与调好了并不是一回事。能散阶段把色散区的束斑尺寸减小了 17 到 86 µm,但这是在它自己的扫描范围内算的,扫描从不测量它接手时的设定值。优化回路里量到的发射度在五次启动里有四次下降,但没有一次的降幅超过自身不确定度的两倍,因此我在这里不声称任何结果。我唯一信得过的一个读数来自该装置自带的发射度测量程序,在启动 E 之前和之后各测了一次,不在优化回路之内:几何平均从 5.34 ± 0.13 µm 变为 5.10 ± 0.05 µm,水平方向下降而垂直方向上升,一边的改善是拿另一边换来的。只有启动 E 做过这样的检查。

出现的问题

部署的优化器里有一个字段在 schema 里换了位置,收敛检查却不出声地解析成空,报告说没有旋钮碰到边界;实际上有一块四极铁在第一阶段的大部分时间里都钉在下限上,包括最好的那个点。FACET-II 的验证只检查一步是否完成,因此只要干净地退出就算通过,不管有没有达到目标。当晚运行人员四次纠正同一个选择(把束损作为惩罚项,而不要中止),这些纠正一条也没有留到下一个会话。

在四个 FACET-II 会话里,智能体看到一些没有连上的读回值,就宣布自己在对着模拟运行。它错了:束流自始至终都在线,而它说没有连上的那些名字大多是它自己编的。运行日志里这样一句自信的话,是任何工具关口都拦不住的失败,它在我的待办清单上。

数字来自 Badger 的运行存档,运行器日志和会话记录,2026 年 6 月和 7 月。完整的文章正与 ALS 团队一起准备。