上个月我们做了一次内部复盘,主题是三周前对某个行业热点走向的判断,结论是错得离谱。当时我们用单体大模型跑了十几轮推演,每一轮输出都逻辑自洽、措辞漂亮,但真实的舆论走势没有按照任何一个版本走。问题不在于模型不够聪明,而在于我把"一个聪明的脑子"当成了"一群各有立场的人"。后来我花了差不多两个月时间折腾 MiroFish 这类多智能体群体模拟引擎,把一件事从"问模型"变成了"养鱼"——给每条鱼一套人格、一份记忆、一张关系网,然后看鱼群往哪边游。MiroFish 这个名字起得挺形象,Miro 有镜面、映照的意思,Fish 指向鱼群,合起来就是在一个被镜像出来的世界里养一群鱼。它要做的事情很具体:把现实里的事件、人群结构和传播路径抽象成可以跑起来的沙盘,让成百上千个带人格设定的智能体自主交互、互相影响,从而观察某种说法在群体中会不会发酵、朝哪个方向发酵、大概需要多久。这篇内容写给两类人:一类是已经用单体大模型做过预测或内容推演、发现结果忽好忽坏的从业者;另一类是准备把多智能体仿真引入自己业务场景的技术同学,比如舆情预研、产品概念测试、剧本走向推演、市场假设验证。我会按我实际跑通 MiroFish 的顺序讲,包括配置、人格设计、事件注入、翻车排查和结果解读。
1. MiroFish 到底在解决什么问题:从"问一个脑子"到"养一群鱼"
1.1 单体模型做推演时的三个硬伤
我最初用单体大模型做走向推演的时候,最大的感受是"每次回答都很对,但就是不准"。后来把失败案例拆开看,问题集中体现在三个地方。
第一个硬伤是立场单一。你不管怎么在提示词里写"请从多个角度分析",模型最终给你的还是同一个人格在同一套价值观下的分裂表演。它可以把"支持方"写得像模像样,把"反对方"也写得像模像样,但这两个角色共享同一套底层偏好和同一套表达习惯。真实群体里那种"我根本听不懂对方在说什么"的错位感,单体模型很难自发产生。
第二个硬伤是没有传播结构。真实世界里的观点不是广播出去的,是沿着关系网一传十、十传百传出去的。你的邻居信什么,很大程度决定了你信什么。单体模型没有这张网,它只能给一个"总体判断",给不出"哪个节点先变、哪个节点最后变"。
第三个硬伤是没有中间态。一次问答给的是一个终局,中间那些反复、摇摆、反转、嘲讽、跑题、突然冒出个不相干的热点把节奏带偏的过程,全被压缩掉了。而这些中间态恰恰是做预研时最想知道的东西。
MiroFish 这类引擎的思路,是把上面三个硬伤分别拆成三个可配置的模块:人格卡解决立场单一,关系图解决传播结构,多轮 tick 循环解决中间态缺失。
1.2 鱼群隐喻:几条局部规则怎么长出全局秩序
鱼群是自然界里最省事的群体智能样本。每条鱼只看得到身边几条鱼,每条鱼只执行分离、对齐、聚合这三条基本规则,整群鱼却会呈现出避敌、绕障、突然集体转向这种高度协调的行为。没有任何一条鱼掌握全局信息,全局秩序是"涌现"出来的。
MiroFish 里的智能体遵循同样的设计哲学。我配置的时候只需要关心三件事:这条鱼看得见谁(局部视野)、这条鱼倾向怎么表态(人格偏好)、这条鱼记得住多久(记忆窗口)。至于整场舆论会不会一边倒、会不会分裂成两个阵营、会不会在第 7 轮突然熄火,这些都不是我写进配置里的,是跑出来的。
这个认知转变对写提示词的影响特别大。我一开始总想把提示词写得面面俱到,把可能的各种情况都提前规制好,结果智能体反而变得僵硬、像在念稿子。后来把提示词砍到很短,只保留身份、当下情绪、最近看到的几条动态,输出的东西反而活了。
提示:如果你之前调过单体模型的提示词,习惯把约束写得很细,上手 MiroFish 时一定要反过来做。智能体的"自由度"是模拟质量的来源,约束太多等于你在提前写剧本。
1.3 什么时候别用 MiroFish
不是所有推演任务都值得上多智能体。下面这张表是我踩过几轮之后的判断标准,可以参考。
| 场景特征 | 适合用 MiroFish | 建议别用 |
|---|---|---|
| 结论形态 | 想知道分布、路径、拐点 | 只想知道一个确定答案 |
| 参与方 | 多方立场明显、互相影响 | 单一决策主体 |
| 时间跨度 | 需要观察多轮演化 | 一次性表态 |
| 数据基础 | 有人群画像或关系数据可喂 | 完全无锚点、纯凭想象 |
| 预算 | 能接受几千到几万次调用 | 只想跑一两次调用 |
最后一行特别现实。我见过有人拿 MiroFish 跑"明天某个小事会不会上热搜",一个智能体池子开了一万条鱼,跑二十轮,最后得出的结论是"存在不确定性"。这种任务本身的信噪比就极低,仿真再精细也只是把噪声放大。
2. 把 MiroFish 跑起来之前,先想清楚这四件事
2.1 算力与并发:先把账算清楚再动手
多智能体仿真的开销是乘法级增长的,不是加法级。调用的总次数大致等于:智能体数量 × 轮次 × 每轮人均调用次数。我做过一个最朴素的估算。
拿一个中等规模的实验举例:1000 条鱼、20 轮、假设每条鱼每轮发言一次,就是 2 万次模型调用。单次调用的输入大致是人格卡加记忆摘要加最近看到的动态,保守按 1200 token 算,输出按 250 token 算,那么总输入约 2400 万 token,总输出约 500 万 token。下面这张表里的单价只是举例,你用自己实际接入的模型价格替换一下就能算出真实成本。
| 项目 | 数量 | 单价(示例) | 小计(示例) |
|---|---|---|---|
| 输入 token | 2400 万 | 4 元 / 百万 | 96 元 |
| 输出 token | 500 万 | 12 元 / 百万 | 60 元 |
| 合计 | — | — | 约 156 元 / 轮实验 |
这个价位对我来说是可以接受的,前提是别乱跑。真正烧钱的是调参阶段——你会想"再改一版人格试试",一晚上跑二十次,账单就不好看了。所以我在配置里强制加了一层成本上限,超过阈值直接熔断,下面会讲怎么加。
并发这一侧反而更容易出问题。一万次串行调用按每次两秒算要五个多小时,所以必须开并发。但并发开太高,接口会限流,日志里会刷一堆超时,反而更慢。我的经验是先从 8 并发起,稳定之后再往上加,每次翻倍,观察错误率曲线什么时候抬头。
2.2 模型接入的三种姿势
我实际试过三种接入方式,各有各的适用面。
第一种是全本地小模型。好处是成本几乎为零、数据不出机器、可以无限次试错,适合调参阶段。坏处是小模型在"扮演一个有具体生活背景的人"这件事上确实差一截,写出来的东西容易同质化,几十轮之后你会看到大量句式雷同的发言。
第二种是全云端大模型。语言质量最好,人格区分度最明显,但成本高、并发受限,而且不好反复试错。我一般只在最终确认的那几次正式实验里用。
第三种是混合,也是我目前最常用的。把智能体分成两层:核心角色(大概占 5% 到 10%,是事件的直接相关方、意见领袖、关键传播节点)走大模型,剩下的背景群众走本地小模型。这个设计的理由很直接:模拟里真正推动剧情的永远是少数节点,群众的作用是提供氛围和基数。用大模型去渲染一千条路人的吐槽,性价比极低。
2.3 配置骨架:一个不容易踩坑的目录结构
我在项目里用的结构大致是这样,你可以按自己的习惯调整,重点是让"可复现的东西"和"跑出来的东西"彻底分开。
mirofish/ configs/ personas.json # 人格卡池 relations.csv # 关系边:from,to,weight seed_events.yaml # 种子事件 runtime.yaml # 并发、轮次、模型、预算 runs/ 2024-xx-xx_exp01/ # 每次实验一个目录 logs/ snapshots/ # 每轮结束的状态快照 outputs/ scripts/ smoke_test.py validate_personas.py run_sim.py# runtime.yaml 关键字段示意 simulation: agents: 1000 rounds: 20 seed: 20240115 # 固定随机种子 visibility_k: 8 # 每个智能体每轮能看到多少条动态 memory_window: 12 # 记忆保留轮数 models: core: provider: cloud temperature: 0.9 concurrency: 8 crowd: provider: local temperature: 1.0 concurrency: 32 budget: max_calls: 30000 # 硬熔断 max_cost: 200把seed、temperature、visibility_k、memory_window这四个值写死在配置里,是我做了很多次"跑完想复现却复现不了"之后养成的习惯。这四个值里任何一个变了,结果都会不一样。
2.4 三件套冒烟测试,别跳过
正式开跑之前,我一定会按顺序跑三个小测试,每个不超过五分钟,但能省掉后面好几个小时的排查。
第一个是单智能体单轮测试。只放一条鱼,喂一条种子事件,看它输出的内容像不像一个真人会说的话。如果这一步输出的东西就已经是"综上所述"这种总结腔,那说明人格卡写坏了,后面一千条鱼也救不回来。
第二个是十智能体三轮测试。这一步主要看互动有没有发生,有没有出现"所有人都在自说自话"的情况。如果互相之间完全不引用、不回应,通常是关系边没生效或者可见性参数配错了。
第三个是关掉模型调用的纯数据管道测试。让模拟跑起来但把模型返回替换成假数据,验证日志落盘、快照、统计脚本这条链路是通的。这个测试最容易被跳过,但恰恰是最值钱的——模型调用是最慢的一环,把数据链路的 bug 和模型的 bug 混在一起排查,会让你怀疑人生。
3. 人格卡设计:模拟质量的分水岭就在这里
3.1 随机人格为什么毁掉整场推演
我最早的做法很偷懒:给每个智能体随机抽几个标签,比如"年龄 25-35、城市、职业随机、性格随机"。跑出来的结果是整场讨论像一锅稀粥,所有人都温和、理性、没有立场,二十轮下来没有一句有攻击性的话。
原因不复杂。随机组合出来的标签之间是没有张力的。一个人"既关心环保又在意成本、同时在纠结要不要换工作",这是张力。一个人"年龄 28 岁、职业是设计师、性格开朗",这不是张力,这只是信息。没有张力的智能体不会和其他智能体产生摩擦,没有摩擦就没有剧情。
真正有效的人格卡,核心不是"描述一个人",而是"给这个人装上一个会让他和别人吵起来的东西"。比如"坚信某种做法是对的,并且认为不这么做的人是因为懒",这种设定一放进去,互动立刻就有了。
3.2 人格卡该写哪些字段
我现在用的人格卡字段大致是下面这些,前四个是必填,后面几个按场景选填。
| 字段 | 作用 | 写坏的典型症状 |
|---|---|---|
| identity | 身份锚点,决定视角 | 太笼统,如"普通网友" |
| stake | 利益相关度,决定投入程度 | 所有人都高相关,全员激动 |
| belief | 核心信念,决定立场方向 | 写成价值观口号,无具体指向 |
| voice | 表达风格,决定文本区分度 | 所有人都是"理性客观" |
| trigger | 触发点,决定何时开口 | 缺失,导致大量沉默 |
| blindspot | 认知盲区,决定误判方式 | 缺失,导致全员全知全能 |
blindspot这一项我要单独说一下。很多人写人格卡的时候会本能地往"聪明"的方向写,但真实的群体里,误判和信息缺失才是推动事件走向的关键。给每条鱼都装一两个认知盲区,讨论才会出现"明明有公开信息但没人注意到"这种情况,而这种情况在真实舆论里极其常见。
3.3 抽样比例比单张卡片更重要
人格卡写得再好,如果一千条鱼里九百条是同一个类型,结果还是单边。我一般会把池子按立场倾向大致分成三到五档,每档占比控制在 15% 到 40% 之间,并且明确留出 5% 到 10% 的"摇摆人"。
摇摆人的作用是决定胜负。大多数时候,立场鲜明的人在开跑前几轮就会把话说尽,之后整场节奏就往摇摆人身上转移。我甚至会在配置里单独给这批人更长的记忆窗口和更高的活跃度,因为他们的表态最能反映"中间地带"的漂移方向。
3.4 用脚本做一次人格分布自检
人格池写完之后,别急着跑仿真,先跑一个纯统计的自检脚本。这个脚本不调用任何模型,纯粹算分布,几秒钟就能出结果。
import json from collections import Counter with open("configs/personas.json", "r", encoding="utf-8") as f: personas = json.load(f) print("total:", len(personas)) print("stance :", Counter(p["belief"]["stance"] for p in personas)) print("voice :", Counter(p["voice"]["tone"] for p in personas)) print("activity:", Counter(p.get("activity_level", "mid") for p in personas)) # 检查是否存在完全重复的人格卡 fingerprints = [p["identity"]["tag"] + p["belief"]["stance"] for p in personas] dup = [k for k, v in Counter(fingerprints).items() if v > len(personas) * 0.15] print("over-represented:", dup)那行 15% 的阈值是我自己定的经验值。超过这个比例,说明某一类人格在池子里太密了,跑出来的结果会明显偏向那一类。
注意:人格池的自检一定要在跑仿真之前做。跑完再发现分布失衡,你损失的不只是时间,还有那一轮已经花掉的调用成本。
4. 事件注入与 tick 循环:让剧情自己长出来
4.1 种子事件怎么写才不会被忽略
种子事件是整场模拟的起点,但绝大多数人把它写成了新闻通稿。我一开始也是这么干的,写了一段两百字的客观描述发下去,结果前五轮几乎没人理,大家都在聊自己的事。
后来我换了写法,把种子事件拆成三个部分:事实核、争议点、钩子。事实核就是发生了什么,一两句话说完;争议点是有哪两种说法在打架;钩子是能让智能体"代入自己"的东西,比如"这件事可能影响到你所在行业的成本"。
争议点是最容易被省略的一环,但它其实是整个模拟的发动机。没有争议,讨论就退化成信息转述,而信息转述对人格的调用是最少的。
4.2 一个 tick 里到底发生了什么
很多人对多智能体模拟的想象是"所有智能体同时思考然后同时发言",实际实现里通常是带顺序的分步执行。我用的这个版本,一轮 tick 大致按下面的顺序走。
- 感知阶段:每条智能体从自己的可见范围内拉取最近若干条动态,组成上下文。
- 记忆召回:从自己的记忆里检索与当前事件相关的片段,拼进上下文。
- 决策阶段:模型判断这条智能体这一轮是发言、点赞、转发还是沉默。
- 生成阶段:如果决定发言,生成内容;如果沉默,只记录一个内部状态。
- 状态更新:写回记忆、更新情绪值和立场值,形成本轮快照。
这个顺序里有两个容易忽略的细节。一是"沉默"也是行为,也要记录,因为沉默率本身就是重要指标。二是"感知"和"记忆召回"是分开的两步,前者是被动的(别人说了什么),后者是主动的(我想起了什么)。把这两步混在一起,智能体会表现得像没有过去。
4.3 结果结构化:别让日志淹掉结论
跑完二十轮,最不缺的就是原始文本。我第一版输出就是一堆 jsonl,打开一看全是字,根本读不出东西。后来加了一层结构化,把每轮的状态聚合成几个数字。
def summarize(round_state): total = len(round_state["agents"]) spoke = [a for a in round_state["agents"] if a["action"] == "speak"] stances = [a["stance_value"] for a in round_state["agents"]] return { "round": round_state["round"], "active_ratio": len(spoke) / total, "stance_mean": sum(stances) / total, "stance_std": (sum((s - sum(stances) / total) ** 2 for s in stances) / total) ** 0.5, "top_keywords": round_state["keyword_rank"][:5], }stance_std这个标准差是最值得盯的一个数。它很小的时候,说明全场观点趋同;它突然变大的时候,通常是某个事件把人群劈成了两半。我一般会把这个值按轮次画出来看曲线拐点,拐点出现的那一轮,往往就是整场模拟里最关键的节点。
4.4 把随机性钉死:中断、回滚与复现
模拟跑到一半中断是常事,可能是网络抖了一下,可能是某个接口限流,也可能是你自己想改个参数重跑。所以断点续跑的能力必须有,而且必须能精确到"第几轮第几个智能体"。
我的做法是每轮结束落一次全量快照,快照里包含每条智能体的记忆、情绪值、立场值、当前可见的动态 id 列表。恢复的时候从快照读回来,然后从那轮的下一轮继续。这里有个坑:如果快照只存了文本没存数值状态,恢复之后智能体的行为会和原来完全不一样,因为它"失忆"了。
复现性还依赖两件事。一是模型侧的随机性,temperature必须固定,能用seed参数的模型尽量用上。二是采样随机性,比如"这轮选哪些智能体活跃",这个也要用固定种子的随机数生成器,不能用系统时间。
5. 我在实际跑 MiroFish 时踩过的坑
5.1 现象一:全场集体沉默,活跃率不到 3%
这个坑最典型。第一次跑完看统计,一千条鱼二十轮总共只有几十条发言,活跃率个位数。我第一反应是模型不行,换了模型还是一样。
排查链路是这样走的。先看日志里有没有生成失败,没有,说明调用是通的。然后把visibility_k从 8 调到 30,活跃率涨了一点但还是低。接着去看被判定为沉默的那些智能体的实际上下文,发现问题在这里:上下文里全是"别人说的话",而我在提示词里写的是"请判断你是否要参与讨论"。这条指令太开放了,模型在没有强动机的情况下,默认选择沉默。
修复很直接:把决策提示词从"是否参与"改成"你此刻最想回应的那句话是什么",强制它去找一个回应对象。同时给每条智能体在人格卡里补了trigger字段,写明什么条件下必须开口。改完之后活跃率从 3% 涨到 40% 左右,这时候才像一个真实的讨论场。
5.2 现象二:情绪一路单边狂奔,第十轮全员同一立场
第二次跑完,stance_std曲线在前三轮是正常的,第五轮开始一路下滑,第十五轮接近零。整场舆论像被磁铁吸住了一样。
这个问题的根因不在模型,在关系图。我当时的relations.csv是用一个简单的相似度算法生成的,结果是"立场相近的人互相连接",也就是典型的回音室结构。在这种结构下,观点不极端才奇怪。
修的办法有两个方向。一是往关系图里注入一定比例的"跨立场边",大概占总边数的 15% 到 25%,让不同立场的人有渠道互相看见。二是给每轮的感知内容做一次混合采样,不能只从邻居里取,要强制包含几条来自不同立场域的动态。这两个改动一起上,stance_std的曲线才回到一个合理的形状——先下降、中段反弹、最后缓慢收敛,而不是直接塌陷。
5.3 现象三:同样的配置跑两次,结果完全不同
我一度以为是自己参数没存住,查了半天配置文件,发现配置一模一样。最后定位到两个地方:一是模型的temperature我在混合模式下只给 core 组设了,crowd 组漏了;二是并发执行时,智能体之间共享的一个状态字典存在竞争写入,导致每轮实际执行顺序不确定。
第二条是真坑。多智能体模拟里,如果不同智能体的更新之间有共享状态,并发就会带来不可复现的结果。我的处理是把每轮拆成"读阶段"和"写阶段":所有智能体先并发地读同一份快照、生成自己的行动,全部收集完之后再串行地应用状态变更。这样并发只发生在计算最重的模型调用环节,状态变更是确定的。
5.4 现象四:账单在半夜翻了三倍
有一次为了赶一个内部汇报,我把并发从 8 调到 64,跑了一个八百条鱼的池子。第二天早上看账单,比预估高了大概三倍。
原因不是并发本身,而是超时重试。高并发触发了接口限流,请求超时,而我的重试逻辑写的是"失败就重试三次",三次都失败就记一条错误日志继续往下跑。结果是同一个智能体在同一轮里被反复调用,成本翻倍,而且因为失败的那些请求上下文没变,生成的内容高度重复。
修复方案有三条。一是在重试上加指数退避,第一次失败等 2 秒,第二次等 8 秒,第三次等 30 秒,并且限制最多重试两次。二是给整个实验加硬熔断,超过配置里的max_calls直接终止,不再往下跑。三是重试失败后不要静默跳过,而是让这条智能体本轮固定返回"沉默",这样至少能保证状态机的一致性。
6. 结果怎么读:从一堆日志里挖出能用的话
6.1 三类指标:传播、极化、收敛
跑出来的东西再多,我实际盯的就是三类指标。
传播类看的是速度和广度:某个话题在第几轮开始被提及、第几轮达到峰值、有多少比例的智能体至少提过一次。这类指标主要用来判断"这个话题能不能起来"。
极化类看的是分布形态:stance_std的曲线形状、是否存在明显的双峰分布、两个阵营之间的互动密度。这类指标用来判断"会不会吵起来、会不会分裂"。
收敛类看的是终局稳定性:最后几轮的立场均值是否还在移动、活跃率是否已经衰减到低位、是否出现了少数几个叙事占据主导。这类指标用来判断"这事会不会很快过去"。
三类指标放在一起看,比任何单一数字都有意义。我见过传播指标很猛但极化指标平稳的情况,那通常意味着话题热度高但争议不大,属于短期刷屏型事件。
6.2 和真实事件对齐的校准方法
刚跑完的模拟结果,绝对不能直接当结论用。中间缺一步校准。
我的做法是找三到五个结构相似的历史事件,用同样的配置跑一遍,看看模拟出来的传播曲线和这些事件当时的真实曲线在形态上差多少。如果模拟的峰值出现得明显更早,说明我的传播速度参数偏快,需要降低转发权重;如果模拟的极化程度远高于真实情况,说明跨立场边还是不够多。
这一步做完,你会得到一组属于你自己场景的修正系数。这组系数比任何一个开源配置都值钱,因为它是对着你的数据调出来的。
6.3 该写进报告里的局限说明
最后说一个容易被忽略但很重要的事:仿真结果怎么呈现。我现在的做法是每次出结论,都会附带一段边界说明,明确写清楚这次模拟里哪些变量被简化了、哪些人群完全没有覆盖、结果的置信区间大概是什么量级。
这不是为了免责,是因为多智能体仿真的输出太像"真话"了。一堆结构清晰、语言流畅的发言记录摆在那里,很容易让人忘记这背后只有一千条带预设的智能体和二十轮循环。把局限写清楚,读报告的人才知道该信到什么程度。
我在实际跑 MiroFish 的过程中体会最深的一点是:这类工具的价值不在于给你一个答案,而在于给你一个可以反复拨动的沙盘。参数一动,鱼群的方向就变了,变多了你自然能看出哪些变量是决定性的、哪些其实无关紧要。这比任何一次性的"预测"都有用。
最后分享一个小技巧。如果你的实验周期比较长,建议在配置里加一个"每日调用预算",跑满了当天就自动停下。我有一次就是因为没加这个,在调人格卡的过程中一晚上烧掉了一整周的预算,第二天只能干看着配置发呆。养成习惯之后,调参会从容很多。