1.2 v0.2 封版:Chess Mode(sim-chess · 技能 · 行为树)
状态说明:v0.2 已封版(打了 tag
v0.2)。这一节先讲「v0.2 长成了什么样」(前九节,按概念铺开),再讲「它是怎么一步步开发出来的、中间踩了哪些坑」(第十节起,按时间线复盘)——后半段是这一版最值得记下来的部分:v0.2 开发得相当艰难,那些坑和为治坑定下的纪律,比功能本身更有价值。
上一节把框架立住了:大脑和世界分离、看-想-安全闸-动-再看的主循环、一圈外围件、一个验证用的 sim-desk 世界。这一节做的是在这套框架上长出第一个真正的应用——陪你下国际象棋。它是 ANIMA 的第一个 skill(技能),全程仿真,为将来接真摄像头 + 机械臂铺路。
一、先看全景:v0.1 → v0.2 加了什么
注意左边那一栏:v0.1 的一切都不变、不重写。v0.2 是「加东西」,不是「改框架」。右边五样新东西——新世界 sim-chess、技能、对弈行为树、棋盘视觉、引擎解耦——下面逐个讲。
⛔ 这里有一条用血换来的硬规则(T0 级):加新东西,不许弄丢旧东西。加 sim-chess 这个新世界时,曾经把启动命令里的世界清单从「sim-desk + sim-chess」写成了只有「sim-chess」,结果把旧世界 sim-desk 从下拉菜单和监控页里整个挤没了。代码本身没坏,是「全集配置被替换而非追加」导致旧世界蒸发。修复 = 默认清单含所有已知世界、文档同时列两个世界。凡是改「全集」类的东西,永远是追加,绝不是替换。
二、新世界 sim-chess:握真值,却「只给画面、不给数据」
sim-chess 是一套完整、能独立运行的下棋程序(占端口 8102)。它握着唯一真值——一个 python-chess 的棋盘;负责判命令成败、推进棋局、判终局、渲染画面、还跑一个内置的电脑棋手。
最关键的一个设计:perceive 给大脑的,只有画面 + 一份极简 state={controllers, phase}(谁执哪一方、对局在哪个阶段),绝不给棋盘的结构化真值(局面 / FEN / 轮次 / 胜负)。为什么真值要故意「藏起来」?因为这样才能逼着 ANIMA 用「看像素」去认局面——跟真机摄像头一模一样的处境。如果世界直接把棋盘数据喂给大脑,那仿真里好用、一上真机(没有这种数据)就全垮。注意 controllers / phase 不是棋盘真值——它是「角色与阶段」这种从静态画面看不出来的元信息(一张定格图分不清「暂停的局」和「结束的局」),所以必须随画面一起明确告诉大脑;棋盘上摆了什么,才是要大脑自己看的。
它还支持三个角色任意两个对弈,每一方的控制者可以是 human(人在网页点子走)、anima(ANIMA 通过 AWI 的 move 命令走)、bot(世界用自己的引擎自动走)。ANIMA 想下,先经 take_seat 自己选一方就座;之后只能单向发走子命令 move(from, to, piece, promotion),世界拿真值试一下、只回 {ok, message}。命令里的 piece 是「我识别成的子」——世界会拿它和真值核对,识别错就判失败,这相当于给视觉识别加了一道免费的纠错。
三、把三个概念彻底分清:技能 ≠ 工具 ≠ 行为树
这是整个 v0.2 最容易搞混、也最重要的地方。很多人会把「技能」当成「一堆工具的打包」,那是错的。在 ANIMA 这套里,三者是三个不同的层:
- 工具 tool = 原子能力:一次调用进、一个结果出,不思考、不维持循环。比如
read_board(看盘)、engine_move(算一步)、diff_move(认出对手走了哪步)。这些封装在一套「棋种工具适配器」里。 - 技能 skill = 一份使用说明书:它教大脑「碰到下棋这类任务,该怎么用工具把它干好」,像 Agent Skills 的 SKILL.md,会被注入到系统提示里。skill 高于 tool、不是 tool、也不是一堆 tool 的拼盘。
- 行为树 = 循环层:负责「怎么转、何时停」——每一拍 tick 一次,自主维持一整局对弈。它既不是 skill 也不是 tool,是更高的一层。
有意思的是,下棋这个 skill 的说明书里全程不出现某一种棋的专有词。因为「走哪一步」由引擎决定、「维持循环」由行为树管,所以留给大脑(说明书)的只有三件「非得用语言或高层判断」的事:解说每一手、解说认输(认不认输由行为树按引擎评分定,不是大脑心算)、听懂用户想继续还是退出。正因为说明书这么「干净」,换五子棋、围棋时只要换一个工具适配器,说明书一字不改。
四、对弈行为树长什么样
行为树用的是 py_trees 库。它的结构其实很好懂:每一拍 = 先感知,再「据状态」三选一。
顶上是一个「一拍」的顺序节点,它先跑 Perceive(看画面、认局面、认出对手有没有走子),然后进一个「据状态决定」的选择节点,三选一:
- 该停就停:用户喊停了 / 终局了 / 失败太多次 / 形势太差该认输了——满足就收尾退出。
- 轮到我就走一手:判断轮到我 → 引擎算一手 → 发命令给世界看成败 → 解说这一手。这一支里任何一步失败,这拍就中断、下拍重来。
- 都不满足就等一拍(轮到对手 / 画面没动):这拍什么都不做。
八个叶子节点全部通过注入的「棋种工具适配器」干活,换五子棋只换适配器、这棵树一行不动——这是典型的策略/适配器模式:通用的树依赖抽象接口,运行时注入具体棋种。还有一个细节:顺序和选择节点都设了 memory=False,意思是每拍都从头重新评估,不沿用上一拍的进度——这对「每拍重新看、重新判断该不该停 / 该不该走」才是对的语义。
五、走一手,数据是怎么流的
把「轮到我就走一手」那一支拆开看,能更清楚地看到哪些是确定性代码、哪里才让大模型介入:
世界渲染出棋盘 PNG → 视觉 read_board 用 64 格模板匹配认出局面、还带个置信度判「看清没看清」→ 多帧确认(候选局面连续几帧一致才采信,单帧抖动不算)→ diff_move 认出对手走了哪一手并推进局面 → 轮到我时 engine_move 引擎算棋(确定性、非大模型)→ invoke "move" 发命令、成功才推进 → Narrate 解说。
这条管线里只有最后一步 Narrate 是大模型介入点,而且整棵树就这一处。走哪一步、判轮次、判终局、判认输——全是确定性代码,不让大模型心算棋(大模型会幻觉出非法招)。连认输都不靠它「感觉」:是引擎给的形势评分连续多拍都极差(约落后一个皇后)才认,阈值和确认拍数都在中央 config 里。解说时也只喂「我执哪方」,不喂完整棋盘 FEN——防止它对着一长串局面瞎编。
六、ANIMA 的眼睛:read_board 怎么认盘
前面反复说「吃像素、不读数据」,那它具体怎么从一张图里认出 64 个格子上各是什么子?
把画面切成 8×8 共 64 格,每一格去和「这种格色」的一整套模板(空格 + 12 种棋子)比一个叫 SAD(逐像素差的绝对值之和)的距离,最像的那个就是这格的子。同时用一个 Lowe 比值检验判置信度:把「最像」和「第二像」的距离一比,如果两者差不多像(比值超过阈值),说明这格「看不清」,就标记出来让上层再看一眼。
这套今天对干净的合成图可靠 100%;接口是「图 → 摆放」,以后接真实摄像头,只把这个识别器换成更强的视觉模型即可,上层(行为树 / 工具 / skill)一行不改。有一点必须强调:「棋盘物理外观」的那套常量,识别器(_vision.py)和渲染器(世界的 render.py)两边必须逐像素对齐——这件事由一个真实的 round-trip 测试(tests/test_vision_roundtrip.py,render→read 100% 一致)守着。
这里也藏着一条教训:本项目曾经在注释里反复写「由 round-trip 测试守住」,但当时整个仓库根本没有这个测试——用文档假装有保障,是「造假让流程看起来通了」的变体。所以现在说「有测试守着」,那个测试文件是真实存在的(和它一起的还有行为树、manager、感知健壮性等几个测试)。说有,就得真有。
七、两层循环,以及多局并发怎么不打架
这里要回答一个结构性问题:上一节的主循环和这一节的行为树,是什么关系?答案是两层不同的循环:
主循环管「一条用户消息」:同步跑,最多 8 步,大脑只出文字就结束,不维持跨消息的状态。行为树管「一整局棋」:在后台线程里每秒 tick 一拍,一局是 N 拍,跨拍维持棋盘状态(存在一块叫「黑板 Blackboard」的便签本上)。为什么要分两层?因为下棋是长回合循环(八九十步),靠主循环硬撑会把上下文喂爆;用行为树把「循环 / 何时停」做成可复用积木,大脑只在该说话时被叫出来。
多局并发由一个 RunnerManager(通用的「多棵行为树运行管理员」,不只管下棋)管,它解决三个真问题:① 按会话路由到对应的对局;② 开新局前先把旧局干净停掉(修过一个真 bug:旧对局线程被覆盖后没人取消、还在后台空跑甚至继续发命令);③ 用一个单写者令牌(epoch)——开新局就 epoch++ 作废旧令牌,旧对局即便没退透,它的「发命令」也会因为令牌失效而拒绝执行,防止旧线程覆盖世界状态。
值得一提的是代码里对这个机制有一段诚实的标注:检查令牌和真正发命令之间不是原子的,旧对局恰好卡在网络往返里时仍可能多发一次命令——软件层不声称「数学上零双写」,真机侧要靠世界端命令幂等 / 序号去重来兜底。把「我这层保证不了什么」如实写出来,比假装万无一失重要。
八、两处解耦:引擎可独立升级,进技能靠大脑判断
第一处解耦——引擎。这里其实有两套引擎:ANIMA 自己用来算棋的引擎(行为树的 EngineMove 经棋种工具适配器调它),和世界 sim-chess 里那个 bot 自带的引擎。两者相互独立、可分别升级。ANIMA 的棋力来自一个独立的引擎项目 3-anima-chess-engine,只有适配器这一处去碰它,而且路径是从仓库结构派生 / 用环境变量覆盖,不写死绝对路径。想让 ANIMA 棋力更强,只换这套引擎就行,其余一律不动。
第二处解耦——意图判断。「用户是不是想开始下棋」「是不是想退出」这种语义判断,一律交给大模型,不靠关键词命中。这一条也是踩过坑才立下的:早期用 any("下棋" in text) 这种关键词来判断「想下棋」,结果用户换个说法("咱俩切磋一盘")就失效了。把本该大脑判断的语义决策写死成关键词规则,是这个项目里最严重的一类硬编码。所以现在进入 / 退出技能都是把「可进入的技能清单 + 用户这句话」交给大模型做意图分类,skill 里也不放任何关键词列表。
九、贯穿这一版的开发原则
v0.2 这一摊代码,从头到尾被一条硬规则管着:禁止硬编码。它不是一句口号,而是具体到每一处:
- 路径从仓库结构派生或用环境变量,代码里不留绝对路径(引擎路径、字体路径都这么处理)。
- 该由大脑判断的(意图、是否退出、走哪步、是否认输)交给大脑,不用关键词 / 规则替它决定——「非-LLM 决策」是最严重的硬编码。
- 可调的数字(每拍间隔、失败上限、看不清比值、确认帧数、认输分、引擎深度)全进中央
config.py,具名 + 环境变量可覆盖,不留魔法数字。 - 声称有的测试 / 能力必须真有:说有测试就得真有、说有能力就得真实现,否则等同造假。
而那些「域常量」——棋盘 8×8、棋子符号、white/black/human/anima/bot 这类枚举——是定义,不算硬编码。分清「定义」和「本该算出来却写死的值」,是这条规则的精髓。
到这里,v0.2「长成了什么样」就讲完了:在 v0.1 的框架上,用「一个只给画面的世界 + 一份干净的说明书 + 一棵确定性的行为树 + 一双吃像素的眼睛 + 两套解耦的引擎」,拼出了 ANIMA 的第一个会下棋的技能。但这一版真正值得记下来的,是它「怎么开发出来」的过程——下面是幕后。
十、幕后:这一版是怎么一步步长出来的
v0.2 不是一口气写完的,是 v0.1 封版之后一轮一轮加出来的。我把每一轮叫一个 wave,一共七个。先看全貌,再说每一轮的事和它踩的坑:
| wave | 这一轮干了什么 | 那一轮的坑 / 教训 |
|---|---|---|
| Wave 1 | 接入下棋世界 + Chess Mode:sim-chess 世界、对弈技能、py_trees 行为树、视觉读盘、可插拔棋种 | 加 sim-chess 时把旧世界 sim-desk 从世界清单里挤没了(§一那条 T0 红线) |
| Wave 2 | 把框架的名字和分层定下来:编排器 / 技能 / 适配器 / 行为树 / 工具 五层;谁持有技能的生命周期;通用运行时;人在环路;脑↔大模型日志 | 定了一个「拉取式认领座位(claim)」的机制——结果从没接线,一直是死的,到 Wave 7 才删掉 |
| Wave 3 | 六件小修:每会话独立的 LLM 日志、修 bot 抢走、选子高亮画进画面、终局信号、五子棋切换、真值调试面板 | —— |
| Wave 4 | 棋桌状态机重构 + 入口 bug:把开始/暂停/认输等做成正式状态机、删掉「自动替大脑选边」 | 状态机一上来就设了四个阶段 + 全局暂停,埋下了后面「太复杂」的种子 |
| Wave 5 | 全系统审计 + 未来路线图:十几个独立 agent 审现状、调研框架走向(只分析、没动代码) | 审计照出一个扎心事实:到第 5 轮了,编排器里还残留着棋类话术没清 |
| Wave 6 | 去棋化 + 填坑 + eval + 开发指南:把编排器里残留的棋类话术彻底清干净、补两个占位、写独立 eval 记分台、把开发纪律沉淀成一份指南 | 这才发现:前五个 wave 一次都没提交,没有回滚点 |
| Wave 7 | sim-chess 收尾:把状态机 / 座位 / 通道命名一次修干净(详见 §十二) | 真机式地连着用一遍,才暴露出 9 个现场问题 |
十一、「艰难的 0.2」:为什么这么难,以及定下的纪律
v0.2 比 v0.1 难太多——难到我给它起了个名字叫「艰难的 0.2」。难的根因不是哪段代码笨,而是缺了几道「闸」,结果每个 wave 都在前一轮的半成品上继续加,脏东西层层叠、没人敢清:
- 没有「提交」这道闸:五个 wave 零提交 → 没有回滚点 → 动核心怕崩了回不去 → 不敢大改。这是编排器迟迟不敢彻底清干净的直接原因。
- 没有「分层干净度」验收:每轮只验「功能跑通」,没验「分层有没有被污染」。于是「你执白、走了 N 手」这种棋类话术,从第 1 轮一路活到第 6 轮才被清掉。
- 决策心脏没有测试网:主循环、安全闸长期零测试 → 重构没有兜底 → 更不敢动。
- 占位埋下没人追:「先填个 None / 先占个坑」埋下去就忘了,没有清单复查。
- 调研容易手痒:审计照出一堆「未来该加的」,很容易现在就上,违背「少做才走得远」。
对症下药,从 Wave 6 起立了一套防雷纪律(沉淀成一份只给自己看的《ANIMA 开发指南》):每个 wave 收尾必提交留回滚点;编排器干净度用一条 grep 黑名单当硬验收(命中必须为 0);加任何东西先回答「这块属于哪一层」,放错层不许合;占位即时登记、收尾逐条过;删配置同步跑「死配置扫描」;动决策心脏前先有测试网再动刀;每轮列一张「先别做」,把「未来才做的」挡在门外。
这套纪律不是教条,是用「艰难的 0.2」换来的。它最大的作用是把「不敢动」变成「敢动」——有了回滚点和测试网,Wave 6、7 才敢把编排器和状态机大刀阔斧地清干净。
十二、收尾这一版:Wave 7 把 sim-chess 的毛病一次修干净
前几轮把大脑侧清干净了,但真机式地连着用一遍 sim-chess,暴露出 9 个现场问题,几乎全挤在 sim-chess 这个世界本身。Wave 7 把它们一次修透,分四类:
- 状态树的 bug(座位 / 阶段):复原棋盘后,旧的控制者没被清掉、残留着,挡住新一局就座(这是个真 bug:复位只重建了棋盘、忘了清座位);game-over 想开新局却落不了座;对弈中明明已经就座,再就座却被「进行中」挡掉。修法:复位 / 换桌时真正清空座位;
take_seat改成幂等(已经坐在这一席再就座直接成功);开新局统一走「复位→配座→开始」。 - 太复杂:状态机原本四个阶段还带全局暂停 / 恢复,组合爆炸。修法:砍掉全局暂停 / 恢复,简化成三阶段(未开始 / 对弈中 / 已结束)。
- 进对局要好几步、还容易「说了开始却没进模式」。修法:一步到位——大脑从对话理解你执哪一方,技能的 launcher 自动替它就座 + 开局 + 起行为树。说一句「我执黑,你先走」就直接开打。
- 旧 claim 机制:一个早就被
take_seat取代、却从没接线的「认领座位」机制还留着残骸。修法:整套删掉。 - 状态 vs 真值,两个通道命名撞车:调试面板上一个叫
state、一个叫status,让人误以为status(上帝视角真值)也该给大脑。其实随画面给大脑的是perceive.state({controllers, phase},确实给了脑),那个「不给大脑看」的是另一个东西=上帝视角真值(含 FEN)。修法:把名字理清——只给人看的那个改叫「调试真值」、不再叫 status,消除撞名;数据流一点没改(真值继续只给人看)。
这些修完,「进入对局之后,下棋本身一直是没问题的」那条体验,从头到尾就顺了。0.2 至此封版。
十三、这一版还顺手立了三块「通用件」(不只为下棋)
下棋是载体,但有三块东西是给所有任务用的,会一直留在框架里:
- 人在环路(HITL):行为树能主动挂起、向人提问、拿到答案从原处继续;还加了超时——人一直不答就安全中止。这对真机是硬需求:舵机臂一断电关节就失力下塌,不能让一个没人回的问题在那儿无限挂着。
- 分级安全闸:动作下发前那道不经过大模型的确定性检查,从「放行 / 拒绝」升成三档——放行 / 需人批 / 拦截。我那条「所有真机命令由人亲手执行」的硬规则,本质就是最高一档的审批门。
- 独立 eval 记分台:一个完全独立的事后分析器,只读对弈档案、按主流象棋标准(ACPL 等)给大脑的棋力打分,单独跑、不碰主程序。它把「能不能下、下得好不好」变成一个可复现的数字——比一句「我做了个框架」有说服力得多。
回头看,v0.2 真正的产物有两样:一个会下棋的技能,和一套「怎么把这种长程任务稳稳开发出来」的纪律。后者,才是「艰难的 0.2」最贵的那部分。