
上个月我们做了一次内部复盘主题是三周前对某个行业热点走向的判断结论是错得离谱。当时我们用单体大模型跑了十几轮推演每一轮输出都逻辑自洽、措辞漂亮但真实的舆论走势没有按照任何一个版本走。问题不在于模型不够聪明而在于我把一个聪明的脑子当成了一群各有立场的人。后来我花了差不多两个月时间折腾 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。下面这张表里的单价只是举例你用自己实际接入的模型价格替换一下就能算出真实成本。项目数量单价示例小计示例输入 token2400 万4 元 / 百万96 元输出 token500 万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, encodingutf-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 的过程中体会最深的一点是这类工具的价值不在于给你一个答案而在于给你一个可以反复拨动的沙盘。参数一动鱼群的方向就变了变多了你自然能看出哪些变量是决定性的、哪些其实无关紧要。这比任何一次性的预测都有用。最后分享一个小技巧。如果你的实验周期比较长建议在配置里加一个每日调用预算跑满了当天就自动停下。我有一次就是因为没加这个在调人格卡的过程中一晚上烧掉了一整周的预算第二天只能干看着配置发呆。养成习惯之后调参会从容很多。