多轮交互¶
多数研究每步只决策一次。但有些研究需要 agent 在这一步结束之前先互相说
话——流言在宿舍里扩散、委员会逐渐收敛、市场对刚刚公开的报价做出反应。
interaction_rounds 就是干这个的。
它是 simulation/simulation.json 里的一个整数,默认 1,引擎会做
max(1, …) 钳位。把它调到 1 以上会改变「一步」的形状,而且很容易搞错——
所以调之前请把这一页读完。
一步到底做了什么¶
flowchart TD
A["advance_to(t) —— 外生事件 + 广播,每步只触发一次"] --> B{轮次 r = 0 … K-1}
B --> C["observe_batch(ids, t, r) —— 四象限 Observation"]
C --> D["decide_batch(obs, memories)"]
D --> E["env.apply(actions) —— 把行为折回 E"]
E -->|"r < K-1:进入下一轮"| B
E -->|"r = K-1:最后一轮"| F["memory.push(last_actions)"]
F --> G["每个 agent 一行面板 · collector.collect() · 落库"]
图里藏着两个结构性事实,它们就是这一页的全部要点。
只有最后一轮会被记录
advance_to(t) 在第 0 轮之前只触发一次本步的定时事件与广播——
中间轮次看不到任何新的外生输入。而且只有最后一轮的行为会进入
agent 记忆、写进面板。第 0…K−2 轮在轨迹里不留任何痕迹:它们存在的
唯一意义,是让环境的信息层能在这一步之内积累起来。如果你需要逐轮
的证据,就自己从 bus 里持久化(见下文 drain())。
轮 ≠ 步¶
这是两条正交的轴,把它们混为一谈是这里最常见的建模错误。
n_steps |
interaction_rounds |
|
|---|---|---|
| 轴 | 纵向轴 —— 真实时间 | 步内交互深度 |
| 单位 | time_unit 说了算(天/周/月/…) |
没有单位;一轮不是时间 |
| 面板行 | 每个 agent 每步一行 | 不产生任何行 |
| 外生事件 | 每步触发 | 步中途永不触发 |
| 记忆 | 每个 agent 每步一条 | 只有最后一轮进记忆 |
n_steps: 12, interaction_rounds: 3 的研究,每个 agent 产出的是 12 行面板,
不是 36 行。那 3 轮只是这 12 行各自「怎么得出来的」。
什么时候该用 1 轮¶
当 agent 的决策是对着世界结算、而不是互相结算时,用默认的
interaction_rounds: 1:
- Schelling / 芝加哥隔离 —— agent 看一眼自己的邻里,然后搬或不搬。 邻居是不是也搬了,那是下一步的问题;模型可解正是因为如此。
- Sugarscape 类资源模型 —— 采集、消耗、迁移。
- 任何问卷型 / 选择型研究:agent 依据自身状态加上广播的信息环境作答。
判断问题只有一句:在同一步之内,A 需不需要看到 B 刚刚做了什么? 不需要, 就是 1 轮。
什么时候该用 K > 1¶
当这一步建模的是一次交流——agent 必须对别人在同一步内说的话做出 反应时,才调高它:
- 讨论与协商 —— 宿舍、家庭、委员会。第 0 轮各自表态;第 1 轮看到舍友 的倾向与理由,可能被说服;第 2 轮群体基本定型。只有定型后的立场才是这个 月的决策——面板记的也正是它。
- 一个周期内的传染 / 意见扩散 —— 一周的扩散包含若干跳传播。
- 市场对公开报价的反应,在本期结清之前。
3 轮通常足以看到收敛;再多基本只是在烧 token。
接线责任¶
核心循环从不碰 MessageBus,它只把 round_idx 传进 observe_batch。
所有轮次机制都是研究自己环境的事:
| 责任 | 谁做 | 在哪儿 |
|---|---|---|
把 round_idx 传下去 |
引擎 | observe_batch(ids, t, r) |
| 持有 bus | 你的 EnvironmentProvider |
一般在 reset(seed) 里建 |
| 把行为变成消息 | 你的 env,调 NeighborFeed.ingest(actions, step, round_idx) |
在 apply(actions) 里 |
| 把消息投递给 agent | 你的 env,调 NeighborFeed.local_for(agent_id, t, round_idx) |
在 observe_batch() 里,塞进该 agent 的局部信息象限 |
| 持久化消息 | 你的 env,调 bus.drain() |
drain() 返回并清空本轮缓冲,正好写进 DuckDB 的 messages 表 |
NeighborFeed.ingest 只路由 kind 为 "post" 或 "reply" 的行为——
move、choose_meal 这类行为会原样穿过它。如果你希望 agent 的观点被同伴
看见,就必须把它作为 post 形状的行为发出(或者直接往 bus 上 post)。
投递是中介式的,不是 O(N²):post 落到 bus 上,每个观察者只读自己邻居
的消息,邻居由你交给 feed 的 neighbors_fn 解析。
可见性规则¶
InMemoryMessageBus.visible_to(recipient, neighbors, step, round_idx) 返回
消息 m,当且仅当作者在收件人的 neighbors 里,并且满足其一:
m.step == step且m.round < round_idx—— 本步更早的轮次发的;carry_previous_step且m.step == step - 1—— 上一步的消息。
举例,interaction_rounds: 3、carry_previous_step=False:
| 轮次 | agent 在 feed 里看到什么 |
|---|---|
r=0 |
本步什么都没有 —— 还没人发言 |
r=1 |
每个邻居第 0 轮的发言 |
r=2 |
邻居第 0 轮和第 1 轮的发言 |
即:第 r 轮看得到第 0 … r−1 轮。对单轮研究,这条规则的后果是绝对的:
interaction_rounds: 1 时,agent 永远看不到同一步内发出的任何东西。
它唯一的 agent 间输入是上一步的消息,而且还得 bus 是以
carry_previous_step=True 建的。一个发消息却把轮数留在 1 的研究,做出来
的是一条延迟一步的通道,不是对话——有时这正是你要的,但要说清楚是刻意的。
第 0 轮的 prompt 也要照此来写:它没有同伴内容可反应,所以应当索取一个
开场立场(「说说你此刻倾向什么、为什么」);而 r ≥ 1 的 prompt 才展示
同伴发言并邀请修正。
代价¶
K 轮 ≈ 每步 K 倍的 LLM 调用
decide_batch 每轮跑一次。一个 12 步、200 agent 的研究,
interaction_rounds: 3 意味着约 7200 次决策而不是 2400 次——换来的
还是每个 agent 12 行面板。轮次买的是步内真实性,价格是线性的。
先用确定性路径把整个循环干跑一遍,见
从零构建 Study。
你会失去热启动
热启动要求 interaction_rounds == 1,不满足时引擎会直接抛错,
而不是错误地重放。原因正是本页开头那条不变式:父版本的面板只留下了
最后一轮的行为,所以多轮的一步无法从已存行为里复现。多轮研究永远从
第 0 步重跑。见迭代、版本与报告。
propagation 不负责这件事¶
population.json 里有 propagation
(independent / contagion / broadcast_then_local),很容易让人以为
它就是打开交互的开关。
它是声明性标签 —— 引擎从不读它
循环里没有任何一处按 propagation 分支。它为研究自己的环境实现记录
「打算怎么传播」,并给 dashboard 的人群卡片打标签。把它设成
contagion 不会让任何东西传播起来;真正让它传播的是你在
observe_batch 里接的那条 feed。请按你实际造出来的东西去填它,
标签与接线不一致就当成文档 bug 处理。
interaction.kind 与 edges 同理:它们描述网络,但真正解析「谁听得见谁」
的是你的 neighbors_fn。
延伸阅读¶
- 一个 Study 的解剖 ——
interaction_rounds、propagation、interaction.kind各自住在哪儿 - 从零构建 Study —— 轮次机制挂靠的四个接口
- 迭代、版本与报告 —— 热启动与版本快照