
第一次把 MiroFish 开到六十条鱼同缸游动的那天晚上我盯着屏幕看了二十分钟没干别的。画面本身并不花哨——就是一堆彩色圆点在深蓝色的网格背景上缓慢移动——真正让我挪不开眼的是其中三条鱼的行为它们在没有写任何一行结群逻辑的前提下先是各自贴着同一个角落来回巡游然后第四条、第五条陆续加入最后形成了一小团缓慢旋转的鱼群。那一刻我才确认MiroFish 这个项目真正想做的事不是做动画也不是复刻一个电子宠物而是用一群各自独立、只能看到局部水情的智能体去观察秩序能不能从混乱里自己长出来。MiroFish 的定位用一句话讲清楚一个可自托管的多智能体沙盒。每条鱼是一个独立的 Agent带自己的性格参数、短期记忆和长期目标它们共享同一张二维水体网格会移动、觅食、消耗体力、发生碰撞、结群或者互相驱赶。你在自己的笔记里给它写过一句话我抄在这里——把观察窗留给人把决策权交给鱼。意思是不要写死每条鱼的行为树只需要定义好环境规则和感知边界剩下的交给模型去博弈。这篇文章适合两类人一类是写过单 Agent 玩具、想再往前一步试试多体交互的人另一类是准备做仿真、沙盒、NPC 行为系统但被成本与不可控劝退的人。下面我把环境结构、Agent 循环、冲突仲裁、成本控制、踩坑排查、可观测性这几块一次讲透所有参数和代码都是我实际跑过、改过、翻过车的版本第三节和第四节的参数表可以直接抄。1. MiroFish 到底在模拟什么把鱼缸拆成三个可替换的层在动手之前我最想强调的一件事是不要一上来就想怎么让鱼显得聪明。MiroFish 这类项目翻车九成不是因为模型不行而是因为环境层、鱼层、观测层糊在一起改一个参数牵动全身最后连这条鱼为什么这么走都说不清。所以第一步永远是分层而且分层要分在可以独立替换的地方而不是按文件目录去分。我把它拆成三层水体层负责物理与资源鱼层负责感知与决策观测层负责把状态变成人能看懂的东西。三层之间只通过两个契约通信——鱼层只允许读水体层的局部视图观测层只允许读一个可序列化的世界快照。这条约束看着像教条实际是把后面所有调试成本都压到最低的关键。1.1 水体层一张不断被改写的网格水体层是整个世界的地基它要回答的问题只有几个空间怎么表示、资源怎么产生和衰减、时间怎么推进。空间表示我选的是离散网格而不是连续坐标加物理引擎原因很直接——确定性。MiroFish 的价值有一大半在于回放我要能把昨晚跑崩的那一夜分钟级重演一遍看看到底是哪条鱼在第 14000 个 tick 做了那个把局面带崩的决定。连续坐标下的浮点累加会让每一次运行都有微小偏差回放就对不上了离散网格每个 tick 的状态都是整数只要你把随机种子和决策日志一起存下来重放就能逐格还原。具体参数上我用的是 96×54 的网格单格 20 像素刚好铺满一个 1920×1080 的窗口鱼缸边缘留两格作为不可通行的玻璃壁。每个 tick 推进 0.5 秒也就是一秒两个世界步。食物按每个 tick 0.6% 的概率随机落在水草区每个食物单位提供 40 点体力食物本身有 300 个 tick 的腐坏计时——这一条是我后来加的因为不加腐坏早期跑十分钟食物就会堆满全图鱼不再有任何觅食压力全部躺平。体力每个 tick 固定衰减 0.15 点移动额外消耗 0.3 点所以一条满体力100 点的鱼如果不吃不喝大约能撑 220 个 tick 也就是不到两分钟。world: grid: [96, 54] cell_px: 20 tick_sec: 0.5 wall_margin: 2 food: spawn_rate: 0.006 energy: 40 rot_ticks: 300 fish: max_energy: 100 decay_per_tick: 0.15 move_cost: 0.3这三个数字连在一起就是一个生态节拍器。食物产出速率决定了这个鱼缸能养活多少条鱼体力消耗速率决定了鱼必须多频繁地去觅食腐坏计时决定了资源不会无限累积。我见过很多人一上来把 spawn_rate 调到 5%结果鱼缸变成了自助餐厅所有鱼的行为退化成走过去、吃掉、站着看上去像卡了。资源压力才是行为的驱动器这句话在做任何 Agent 沙盒的时候都成立。1.2 鱼层每个 Agent 的感知-决策-行动闭环鱼层是整个项目里最容易写复杂的地方因为大家都想让鱼更聪明。我的建议正相反把每条鱼的决策链路压到最短短到你可以在脑子里模拟一遍。MiroFish 里一条鱼的一轮循环只有三个动作——感知、决策、提交。感知阶段它只能拿到三样东西自己 5×5 范围内的格子内容水、食物、其他鱼、障碍自己的体力与年龄以及最近 8 条短期记忆。注意它拿不到全局地图也拿不到其他鱼的内部状态更不知道现在全场有多少条鱼。这种局部的、残缺的视野是刻意的一是控制 token 成本二是只有信息不完整博弈和误判才会出现行为才会像活的。决策阶段把上面这些东西塞进提示词让模型输出一个动作代号。这里有个很关键的工程约定模型输出的必须是一个枚举值而不是自然语言。我一开始允许它自由发挥结果出现了大量我想游向那片水草但也许应该休息一下这种没法落地的话解析器天天炸。改成枚举之后提示词里明确给出动作清单和每个动作的前置条件输出格式用 JSON Schema 约束异常率从两位数掉到了千分之几。行动阶段不由鱼自己完成而是提交给仲裁器统一落地。这一点后面第三节会详细讲这里只要记住一个原则鱼的决策是意图世界的改变是仲裁后的结果。把这两件事分开你才能在两条鱼同时想进同一格的时候有地方做裁决也才能保证回放时重放的是一串意图而不是一串已经改过的状态。1.3 观测层把不可见的状态变成可看的东西观测层最容易被当成画个图而已但 MiroFish 里它的重要性不亚于前两层。原因很简单多智能体系统最容易陷入的状态是看起来在动其实什么都没发生。你盯着屏幕看十分钟鱼一直在游但你不知道它们是按照记忆在做计划还是因为超时降级成了随机漫步。观测层要做的事就是把这种看不见变成看得见。我的做法是让观测层只依赖一个东西——世界快照。每个 tick 结束世界序列化成一个纯 JSON 快照推给前端前端渲染成画面同时把快照存进环形缓冲区。这一个设计让后面所有的调试手段都成了可能回放、单鱼追踪、动作分布统计、状态时间旅行全都建立在世界状态可序列化之上。层级核心职责关键约束可以换掉的部分水体层空间、资源、时间推进全程整数、确定性网格可换六边形资源模型可换多种群鱼层感知、记忆、决策只读局部视图、只输出枚举模型可换本地/云端记忆可换向量库观测层渲染、埋点、回放只读快照、不写世界前端可换 Canvas 或终端字符画我自己最常用的其实是终端字符画版本——96×54 用 ASCII 直接打在控制台里比图形界面早发现问题。有一次鱼群集体卡在右下角图形界面上看不出异常切到字符画才发现是右下角有一片食物因为腐坏计时写错永远不会消失所有鱼都被吸引过去了。这就是观测层带来的价值你需要的不是更好看的图而是更容易看出反常的图。2. 让一条鱼活起来Agent 循环里的四个关键决策分层搭好之后真正的难点才出现怎么让一条鱼的行为看起来像是有想法的。这一节我把整条链路上最关键的四个设计决策拿出来讲每个决策我都会说清楚当时的备选方案、我为什么选它以及换掉它会带来什么后果。这四个点分别是感知如何编码、记忆如何分层、动作空间如何收敛、决策频率如何与成本换算。它们共同决定了一件事——你的鱼缸是一群会动的点还是一群会做选择的鱼。2.1 感知不是把整个鱼缸塞进提示词新手最常见的做法是把整张地图网格序列化成文本塞进提示词觉得信息越多模型越聪明。我实测过这条路结论是反面局部视野 5×5 加上自身状态鱼的行为反而更像鱼给全局地图之后所有鱼都会直奔最近的富矿区行为高度同质化看着像装了同一个导航。更现实的问题是成本——96×54 全图有 5184 个格子哪怕只用一个字符表示一次决策的输入就接近六千字符六十条鱼每两秒决策一次账单会以肉眼可见的速度往上跳。局部视野的编码方式我试过三种最后留在线上的是紧凑字符串。5×5 一共 25 个格子用.表示水、#表示障碍、F表示食物、f表示其他鱼中间用自己所在的标记位置整块视野就是 25 个字符再配上换行。这种编码对模型非常友好它天然能理解二维布局而且 token 消耗极低。相比之下 JSON 描述虽然结构化但每个格子都要写成{x:1,y:0,type:water}同样的信息量要贵二十倍以上而且模型在长 JSON 里容易数错坐标。def perceive(world, fish): view world.slice(fish.pos, radius2) # 5x5 网格 rows [] for dy, row in enumerate(view): line [] for dx, cell in enumerate(row): if dx - 2 0 and dy - 2 0: line.append() elif cell.obstacle: line.append(#) elif cell.food: line.append(F) elif cell.fish_id: line.append(f) else: line.append(.) rows.append(.join(line)) return \n.join(rows)这里有个小细节值得说不要把其他鱼的 id 暴露给模型。我一开始把邻居编号写进视野结果模型开始记住某只鱼在记忆里写上次 7 号抢了我的食物然后一直追着 7 号跑整个鱼缸变成了私人恩怨现场。后来改成只输出f不给身份鱼的社交行为立刻变得自然了很多——它有这里有同类的概念但没有仇人的概念这更接近真实鱼缸里的观察体验。2.2 记忆短期环形缓冲加长期摘要的两级结构如果只给鱼当前视野它会变成一条金鱼每两秒重新认识一次世界。所以记忆是必须的但记忆不能无脑堆。我的方案是两级短期记忆是一个长度为 8 的环形缓冲记录最近发生的事件格式统一为tick 数 事件类型 简短描述比如t1420 吃到食物 energy40长期记忆则是每 20 个 tick 触发一次摘要把最近这段经历压缩成一到两句话写进一个固定长度的字段里最多保留三条。这个设计的好处是上下文长度恒定。不管这条鱼活了五百个 tick 还是五万个 tick它的提示词长度始终稳定在视野 25 字符加八条短期记忆加三条摘要估算下来一轮输入在 350 到 500 token 之间。如果不做摘要跑一个通宵之后单条鱼的上下文会膨胀到几万 token别说成本模型本身也会开始忘掉最早的内容得不偿失。class FishMemory: def __init__(self, capacity8): self.short collections.deque(maxlencapacity) self.long [] # 最多 3 条摘要 self.since_summary 0 def record(self, tick, event): self.short.append(ft{tick} {event}) self.since_summary 1 if self.since_summary 20: self.long (self.long [summarize(list(self.short))])[-3:] self.since_summary 0摘要这一步有个坑我后面第五节会展开讲——摘要会漂移。如果摘要的输入只有上一次的摘要和最近的短期记忆几轮之后记忆就变成了自我复述的复述内容会不断抽象化最后所有鱼都变成一句我在鱼缸里游动。修正办法是把摘要的输入里始终保留一部分原始事件锚点并且对人格字段做冻结不允许摘要改写。这个改动听着很小但它决定了你跑一晚上之后鱼还是不是原来那条鱼。2.3 行动空间为什么我把动作压到 6 个动作空间是行为质量的天花板也是成本的隐形开关。我一开始设计了十六个动作包括快速冲刺隐蔽试探性接近示好等等听上去很丰富结果模型频繁在相似动作之间摇摆行为反而迟钝。原因不难理解动作越接近模型越难稳定选择每次决策都在做无意义的纠结。后来我把动作砍到六个向上、向下、向左、向右、进食、休息。就这六个行为丰富度反而上去了——因为复杂度从动作本身转移到了动作的组合与时机上这正是涌现的来源。动作前置条件体力消耗决策占比实测备注move_n目标格可通行0.3约 41%四个方向合计占比move_s同上0.3-同上move_w同上0.3-同上move_e同上0.3-同上eat所在格或相邻格有食物0.1约 12%体力提升 40rest无0约 6%体力回复 0.2/tick这张表是跑了大约四十万次决策之后统计出来的有两个数字值得注意。第一休息只占 6%说明大部分鱼在大多数时候并不处于需要原地恢复的状态如果你看到休息占比超过 30%基本可以判断是资源不足或者超时降级了。第二四个方向的动作分布如果出现明显偏斜比如向上占 60%那多半是提示词里的坐标描述让模型产生了方向偏好这是很隐蔽的一类偏差不埋点根本发现不了。2.4 决策频率与 token 成本的换算多智能体沙盒最劝退的地方是账单。我在这里给一个可以直接套用的估算方式假设有 N 条鱼每条鱼平均每 T 秒决策一次单次决策的输入输出合计消耗 C 个 token那么每小时的 token 消耗约为3600 / T × N × C。用我自己的配置举例N60、T2 秒、C≈500算下来每小时大约 5400 万 token。这个数字放在大模型上显然是跑不起的所以 MiroFish 从设计之初就必须做分级调度这也是第四节的主题。真正让我意识到决策频率本身就是个设计参数的是一次对比实验我把所有鱼的决策间隔从 2 秒改成 4 秒token 消耗立刻砍半但行为质量几乎没变化。进一步分析发现鱼缸里真正值得思考的时刻非常稀疏——大部分 tick 只是平静地游动用规则就能处理得很好。于是我把决策改成了事件驱动只在视野里出现食物、邻居数量变化、体力低于阈值、或者距上次决策超过硬超时的时候才真正调用模型。这一改实际调用量掉到了原来的 18%而画面上一丁点区别都看不出来。3. 多鱼共存的秩序问题冲突、结群与资源竞争单条鱼能跑通之后把数量加到十条、三十条你会立刻遇到一类全新的问题它们会互相踩。两条鱼同时想进同一格怎么办三条鱼围着一个食物同时想吃怎么办十几种行为叠加之后某个角落会不会形成永久的死锁这一节讲的就是多体带来的秩序问题。我的核心观点是秩序应该是仲裁出来的不是规则写出来的。你越想用显式规则去规定鱼该怎么共存最后得到的系统就越僵化稍微改个参数就崩。3.1 空间冲突与优先级裁决最基础的冲突是格位争抢。在 MiroFish 里鱼不直接改自己的坐标而是提交一个我想移动到 (x, y)的意图由仲裁器在每个 tick 结束时统一处理。仲裁器收集所有意图按目标格分组同一格里如果有多个意图就按优先级排序只有一个能成功其余全部降级为原地停留。这个降级不是失败鱼会在下一轮决策时看到自己没动并把这件事写进记忆于是它开始考虑要不要换个方向。优先级的排序规则我改过三次。第一版按体力排序体力低的优先逻辑上说得通——快饿死的鱼更急。结果出现了体力越低越有优先权的反向激励鱼学会了故意不吃饭来抢路权一度出现了多条鱼集体挨饿围在一个路口的现象。第二版改成随机但随机种子必须进日志否则回放对不上。第三版才是现在用的先把年龄和体力做一次加权打分同分的时候用tick 数 鱼 id的哈希做确定性平局裁决。确定性这一个要求千万别省它看似只是让回放一致实际上是让你能在 bug 出现之后复现它。def arbitrate(world, intents): by_cell collections.defaultdict(list) for fish, target in intents: by_cell[target].append(fish) for cell, contenders in by_cell.items(): contenders.sort(keylambda f: (-score(f), tiebreak(f, world.tick))) winner contenders[0] world.move(winner, cell) for loser in contenders[1:]: loser.memory.record(world.tick, 被拒绝移动) loser.stuck_ticks 1stuck_ticks这个计数器后来成了最有用的指标之一。它记录一条鱼连续多少次想动却没动成如果某个区域附近的鱼普遍stuck_ticks偏高说明那里存在拥堵或者死锁通常是因为地形形成了死角或者食物刷新点靠在玻璃壁旁边导致所有鱼都想挤进去。这个字段后来直接进了观测层的告警规则。3.2 结群行为是涌现出来的不是写出来的回到开头那个让我看了二十分钟的场景。三条鱼自发结群我复盘了很久才搞明白触发链条视野里出现同类会提高安全感这个隐含分数而安全感是通过提示词里的一句话间接注入的——我在系统提示里写了独处时你更容易被水流冲散靠近同类会更稳。注意这句话没有规定任何行为它只是给了模型一个倾向。真正的行为产生过程是这样的一条鱼在角落休息第二条路过看到同类选择留下第三条看到两条同类判断这里更稳也留下于是形成了一个正反馈。我试过另一种做法直接在代码里写如果视野内同类数量大于等于 2则 60% 概率停留结果鱼群的形态变得非常机械永远是标准的蜂窝排列而且一旦我把概率改成 55% 或 65%整个鱼群的规模就会突变完全没有中间态。这让我确认了一件事涌现行为需要的是倾向不是规则。你可以给模型调性、给环境压力但千万不要给行为公式否则你得到的只是伪装的规则系统。3.3 资源衰减与生态崩溃的临界点鱼缸能稳定运行多久取决于资源产出和消耗的平衡。这个可以算出来。设鱼的数量为 N每条鱼每个 tick 的平均体力消耗为 d含移动每个食物单位提供 e 点体力食物每个 tick 的产出概率为 p等价于每个 tick 期望产出 p 个食物单位。那么系统可持续的条件是p × e ≥ N × d。代入我的参数e40d≈0.4含移动的加权平均p0.006左边是 0.24右边在 N60 时是 24。差了整整一百倍。这个计算说明我早期的参数设定根本不成立鱼缸能跑起来完全是因为食物腐坏之前就被吃掉了属于抢跑。当我真正把 N 提到 60 条时崩溃来的非常干脆前十分钟一切正常第十四分钟开始出现第一批体力低于 20 的鱼第十八分钟它们开始忽略视野里的障碍强行贴边移动因为提示词里饥饿的权重压过了其他判断第二十二分钟全缸进入低体力状态行为退化成只剩移动和休息画面看起来像一盘散沙在漂。我后来把解决方案分成两步一是把食物产出概率调到 0.06二是给食物加了分区刷新——让食物优先落在空旷水域而不是角落避免所有鱼挤成一团。改完之后同样六十条鱼连续跑了六个小时没出现集体低体力。参数早期值崩溃时的表现调整后效果spawn_rate0.00622 分钟全缸低体力0.06六小时稳定rot_ticks无食物堆积、行为退化300恢复觅食压力刷新位置全图随机角落拥堵空旷水域优先stuck_ticks 下降 70%移动消耗0.2长距离觅食成本过低0.3出现路径选择行为最后一行是我没预料到的收获。移动消耗从 0.2 提到 0.3 之后鱼开始出现明显的路径取舍——它不再无脑冲向视野里最远的食物而是更倾向于吃身边的、哪怕小一点的那份。这个行为没有任何人教它纯粹是成本压力逼出来的。这就是为什么我反复强调资源压力才是行为的驱动器。4. 性能与成本100 条鱼怎么不把机器烧穿六十条鱼能跑稳之后我做过一个贪心的尝试把数量加到 120。结果是本地机器 CPU 跑到 70%模型请求排队画面上鱼的动作开始出现明显的顿挫感有些鱼两秒才动一格有些鱼连着五秒不动。这一节讲的是把这套东西从能跑推到能跑很多的工程手段核心思路只有一条把模型用在真正需要它的地方。听起来像废话但真做起来需要拆得非常细。4.1 事件驱动替代轮询轮询的写法最省心每个 tick 让所有鱼都问一次模型实现简单但成本随鱼数线性上涨而且绝大部分决策是浪费。事件驱动则相反只有在世界发生了值得注意的变化时才触发决策。我定义了四类触发条件体力跌破 25%、视野内首次出现食物、视野内邻居数量发生变化、距上次决策超过 6 秒的硬超时。前三条保证鱼对环境变化有反应第四条保证它不会因为环境一直平静就永远静止——这条很重要不然一条待在空旷水域的鱼会永远不动看起来像死了。HARD_TIMEOUT 12 # tick约 6 秒 def needs_decision(fish, world): if fish.energy fish.max_energy * 0.25: return True if world.view(fish).food_count() fish.last_food_count: return True if world.view(fish).neighbor_count() ! fish.last_neighbor_count: return True if world.tick - fish.last_decision_tick HARD_TIMEOUT: return True return False实测下来这套规则把每条鱼的平均决策间隔从 2 秒拉长到了 11 秒左右实际调用量降到原来的 18%。要注意的是硬超时不能设太长我一开始设了 40 个 tick结果画面看上去像在放幻灯片因为大量鱼处在没有事件的状态集体静止。经验和数值上硬超时最好控制在平均决策间隔的两倍以内。4.2 缓存与批处理别让相同的输入问两遍第二个省钱的大头是缓存。多智能体系统里不同鱼的上下文重复度其实非常高——同样的视野布局、同样的体力区间、同样的动作清单很多鱼在同一时刻拿到的提示词前缀是完全一样的。我把提示词拆成固定前缀 可变部分固定前缀包括人格设定、动作清单、输出格式说明这部分在所有鱼之间共享可以走前缀缓存。实测这一项把输入 token 的计费压掉了大约 55%因为前缀本身占了整个输入的六成左右。批处理是另一个手段。同一个 tick 里有二十条鱼需要决策每条单独发一个请求和打包成一个批次发出去延迟差别不大但吞吐能差好几倍。我用一个简单的批收集窗口实现调度器把这一轮所有待决策的鱼收集起来加一个 80 毫秒的等待窗口窗口结束统一提交。80 毫秒这个值是我试出来的再短起不到批处理效果再长画面上会感觉到整缸鱼同时动一下的同步感破坏自然度。async def batch_decide(pending: list): await asyncio.sleep(0.08) # 批收集窗口 jobs [build_prompt(f, world) for f in pending] results await llm.batch(jobs, schemaACTION_SCHEMA) for fish, action in zip(pending, results): fish.intent action4.3 本地小模型与云端大模型的分工最关键的一招是分级路由。不是所有决策都值得用大模型。在 MiroFish 里往哪走这类常规决策占了九成它需要的只是局部判断和环境理解一个跑在本地的七 B 级别模型完全够用而且延迟低到可以忽略。真正需要大模型的是三类场景新奇事件视野里出现了从未见过的组合、社交冲突多个同类同时想吃同一份食物、以及需要回忆的场景长期记忆被触发。决策类型占比路由目标延迟理由常规移动约 72%本地小模型 200ms模式固定成本敏感觅食判断约 15%本地小模型 200ms规则性强冲突与社交约 8%云端大模型1-2s需要权衡与语气新奇事件约 5%云端大模型1-2s需要泛化能力兜底降级剩余规则引擎 1ms只保证不静止这张路由表让整体成本降了一个数量级同时保住了最需要聪明的那些时刻。这里有个细节兜底规则要设计得体面。我早期用的兜底是原地休息结果一旦模型不可用整缸鱼集体躺平画面死一样安静。后来改成朝体力方向或食物方向做一次简单寻路即使降级也看不出异常。5. 我踩过的坑从鱼集体卡在墙角到记忆污染前面讲的都是结论这一节我按时间顺序还原三个最典型的故障每个都给出完整的排查链路包括我一开始的错误猜测。写这一段的原因是多智能体系统的 bug 大多不是崩溃式的而是行为慢慢变得不对你不主动去找它就会悄悄污染你所有的实验数据。5.1 现象一所有鱼挤在同一个格子边缘第一次发现这个问题是在跑第 8000 个 tick 左右画面上所有鱼都在右下角堆成一坨密度高到看不出个体。我最开始怀疑是碰撞检测写错了翻了半天代码没发现异常然后怀疑是食物刷新点固定在了角落检查日志发现食物分布是均匀的。真正的线索来自stuck_ticks埋点——那些鱼的 stuck_ticks 普遍在 40 以上说明它们一直在尝试移动但一直在被拒绝。也就是说不是它们想挤在一起而是它们想离开但走不掉。顺着这条线查下去问题出在两处叠加。一是平局裁决用了同一个随机种子导致在完全相同条件下永远是同一个 id 的鱼获胜那条鱼反复成功移动其他鱼反复被拒形成了规律性的循环二是边界感知在提示词里缺失鱼不知道自己贴着玻璃壁视野里 5×5 有三格是.水它以为前方畅通于是反复朝墙提交移动意图。修复方案很直接把平局裁决改成基于 tick 和鱼 id 的确定性哈希保证同一时刻不同鱼的优先级不同、不同时刻同一条鱼的优先级也会变化同时在视野渲染里把边界明确画成#让模型知道那是墙。改完之后我特意做了回归测试同样跑 8000 个 tick密度最高的单格鱼数从 17 降到 3stuck_ticks 的最大值从 40 降到 6。5.2 现象二记忆污染导致性格漂移第二个坑更隐蔽。项目跑了大概三天之后我注意到鱼的个体差异几乎消失了——所有鱼说话记忆里的事件描述的口气都差不多行为也趋同全部变成缓慢游动、看到食物就吃的标准模板。我一开始以为是模型的问题换了个模型现象依旧。然后我开始导出单条鱼的记忆做对比看到了问题的根源。那条鱼的长期记忆第一条是我在鱼缸里缓慢游动第二条是我继续在鱼缸里游动寻找食物第三条是我仍在鱼缸中游动。三条摘要全是自我复述完全没有具体事件。原因在于摘要的输入只有上一次摘要加上最近的短期记忆而短期记忆本身已经被上一轮摘要洗过一遍信息不断被抽象几轮之后就只剩最泛化的描述。更糟的是人格字段也被摘要覆盖了——我在实现里图省事让摘要返回一个包含人格描述的对象结果人格被一代代摘要慢慢磨平。修复分三步第一摘要的输入里必须保留至少三条原始事件锚点格式是带 tick 数的具体事件不允许被二次抽象第二人格字段冻结摘要不允许改写只能追加第三给每条鱼的记忆加一个漂移度指标计算方式是当前摘要和初始人格描述的语义相似度低于阈值就告警。改完之后跑了一天鱼之间的行为差异肉眼可见地恢复了——有的鱼爱待在角落有的鱼爱绕着鱼缸边缘巡游。5.3 现象三跑了一夜早上发现全在发呆这个坑最像生产事故。我睡前让鱼缸跑着早上打开屏幕所有鱼都停在原地一动不动看着像集体睡着了。日志里的第一反应是模型请求全部失败检查之后发现确实是——但我用的是免费的额度半夜被限流了。真正的问题是为什么限流之后鱼会全部发呆答案在兜底逻辑里我当时的降级行为是体力低于阈值就休息否则原地等待。原地等待意味着不提交任何意图不提交意图意味着不移动不移动又不会触发新的决策于是整缸鱼静默了。这次修复我做了三件事兜底行为改成基于视野的简单寻路即使模型不可用鱼也会朝着最近的食物或开阔水域移动增加心跳检测如果连续 20 个 tick 没有任何一条鱼成功移动就发告警给请求加上配额监控用量到 80% 时自动切换到本地模型。这三个改动之后同样的限流场景我故意复现过一次画面上的表现是鱼的动作略微变慢、少了一些社交行为但整体依然在正常运转肉眼几乎看不出来。现象第一直觉猜测真实根因修复手段验证方式鱼挤在同一格碰撞检测有 bug平局裁决固定 边界未感知哈希裁决 视野画墙8000 tick 后最大密度 3性格漂移模型能力不足摘要自我复述 人格被覆盖原始锚点 人格冻结 漂移度告警单鱼记忆对比一眼可辨集体发呆程序崩了限流触发降级为原地等待兜底寻路 心跳告警 配额切换主动复现限流运行正常这张表我贴在显示器旁边贴了很久。它最大的价值不是记录答案而是记录了我三次错误的直觉——碰撞检测、模型能力、程序崩溃没有一个是对的。多智能体系统的排查思路应该是反过来的先看埋点数据再看日志最后才去看代码因为代码是你写的、你相信的东西你的直觉天然会替你开脱。5.4 一套可复用的排查链路上面三个坑的排查过程事后看其实可以总结成一条固定的链路我现在遇到任何行为不对的问题都按这个顺序走。第一步永远是锁定时间窗口从日志里找出行为开始异常的 tick把那个时间点前后各 500 个 tick 的快照和决策记录单独导出来。这个动作看起来笨但它把所有偶尔发生变成一定能再现。第二步是做差分把正常时期和异常时期的数据做对比我一般先比动作分布再比 stuck_ticks最后比记忆长度。动作分布能最快暴露退化stuck_ticks 暴露拥堵记忆长度暴露上下文异常。第三步是把单个 agent 隔离出来用同样的种子和输入重放它的决策序列看它在哪一步开始偏离预期。我要特别提醒的是第三步里最容易犯的错误——把 agent 隔离出来之后它往往表现得很正常。因为多智能体系统里的异常大多是交互产生的单独一条鱼看不到同类根本不会触发冲突路径。所以隔离重放之前必须把当时它视野内的其他鱼的状态一起还原。我第一次排查记忆污染的时候就栽在这里隔离测试完全正常白折腾了两个小时。6. 可观测性怎么知道这个鱼缸是活的最后一节讲埋点。很多人做完多智能体项目之后最大的困惑是不知道该怎么判断它好不好。画面好看不等于系统健康鱼在动也不等于决策在起作用。我后来给 MiroFish 定义的活着有三个标准行为有分布、个体有差异、异常可定位。这一节就围绕这三个标准讲我实际用的指标和方法。6.1 我常看的六个指标第一个是动作分布熵。把全体鱼在一个时间窗口内的动作统计出来算一个熵值熵越高说明行为越多样。健康状态下这个值大概在 1.6 到 1.9 之间六个动作的理论最大熵约为 1.79实际因为方向动作可合并我按四类算。如果熵掉到 1.2 以下基本可以判断出现了行为退化要么是资源过多导致只剩移动和进食要么是模型降级了。第二个是冲突率也就是每个 tick 被仲裁拒绝的意图占总意图的比例。健康区间在 3% 到 8% 之间。低于 3% 说明鱼缸太空鱼之间没有交互涌现根本不会发生高于 15% 说明太拥挤鱼会把大量时间浪费在无效移动上。第三个是结群度定义为一个窗口内视野内同类平均数量超过 1 的鱼占全体的比例。这个指标我不设目标值只看它的变化曲线因为结群本身是涌现的结果强行设定目标值会诱导你去写规则。第四个是单鱼 token 消耗用来及时发现某条鱼的上下文异常膨胀。健康值我在前面算过一轮在 350 到 500 token 之间如果某条鱼持续超过 1500几乎可以肯定是记忆出了问题。第五个是决策延迟 P95用来发现批处理和限流的问题。第六个是兜底触发率也就是走规则引擎而不是模型的比例这个值超过 20% 就说明你的模型服务有问题了虽然画面看不出来。指标健康区间偏低意味着偏高意味着动作分布熵1.6 - 1.9行为退化过于混乱检查提示词冲突率3% - 8%交互不足过度拥挤单鱼 token350 - 500记忆被裁剪记忆泄漏决策延迟 P95 1.2s-批处理窗口或限流过小兜底触发率 5%-模型服务异常6.2 回放与时间旅行前面反复提到确定性这里说清楚它到底带来了什么。因为世界状态是整数、随机种子有记录、所有决策都写进了日志我可以做一件很有意思的事把某一夜的运行完整重放一遍而且可以变速。慢放看某条鱼在关键 tick 前的视野快进看鱼群怎么从一个点扩散到全缸。我甚至做过一次干预回放——在重放过程中替换掉某一条鱼在某个 tick 的决策看整缸的走向会发生什么变化。有一次我把一条鱼回头的决策改成继续前进二十分钟后鱼群的聚集位置完全不一样了。这件事的工程价值在于多智能体系统的因果链条很难靠读代码理解只能靠回放观察。你写下的每一行代码都可能通过七八层间接影响最终行为唯一可靠的验证方式就是可控地重放和干预。6.3 一条鱼的体检报告最后分享一个我用得最多的小工具单鱼报告。给定鱼 id 和时间区间输出一份汇总包含动作分布、平均体力曲线、记忆长度变化、社交次数、漂移度、stuck_ticks 峰值。这份报告把这条鱼是什么性格变成了可以比较的数字。比如我有一条编号 42 的鱼它的探索比例长期高于群体均值 20%社交次数只有群体均值的一半漂移度始终很低——它在我的实验里成了独行侠的典型样本。做这个工具的投入大概只有一个下午但它让后续所有的参数调整都变成了可衡量的实验而不是凭感觉调。我现在每次改参数都会先跑一个固定种子、固定时长的基准测试然后对比全体指标和几条代表性鱼的报告超过阈值才认为这次改动有效。这套流程看起来有点重但对于一个会跑几个小时甚至一整夜的系统来说没有观测就等于蒙着眼睛调参。7. 关于这套东西还能往哪走最后说点我个人的使用体会。MiroFish 跑到今天我最喜欢的其实不是鱼群结群的那一刻而是翻日志的时候看到某条鱼在记忆里写下t18934 我在空旷水域待了很久似乎应该去有同类的地方看看——这句话是它自己写出来的没有人提示它要表达这种感受而它下一轮真的朝鱼群的方向移动了。这种行为和叙述对上了的瞬间比任何漂亮的指标都更能说明这套系统在工作。如果你打算自己动手我给三条建议。第一从十条鱼开始别一上来就追求数量十条鱼跑不顺一百条只会把问题放大十倍。第二先把观测层搭起来再写决策逻辑哪怕只是终端里的字符画加一个动作分布统计它的回报会比你在提示词上多花的那些时间大得多。第三任何一次参数改动都跑固定种子的基准测试多智能体系统里感觉变好了是最不可靠的判断。至于后续扩展我现在在试的方向是给鱼加上环境记忆——让它们记住哪些位置曾经出现过食物从而形成区域偏好这样鱼缸里就会慢慢长出领地的概念另一个方向是把单缸扩展成多缸让鱼可以在缸之间迁移那样博弈的层次会再上一个台阶。等跑出结果再写下一篇。