跳转至

多轮交互

多数研究每步只决策一次。但有些研究需要 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" 的行为—— movechoose_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: 3carry_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 里有 propagationindependent / contagion / broadcast_then_local),很容易让人以为 它就是打开交互的开关。

它是声明性标签 —— 引擎从不读它

循环里没有任何一处按 propagation 分支。它为研究自己的环境实现记录 「打算怎么传播」,并给 dashboard 的人群卡片打标签。把它设成 contagion 不会让任何东西传播起来;真正让它传播的是你在 observe_batch 里接的那条 feed。请按你实际造出来的东西去填它, 标签与接线不一致就当成文档 bug 处理。

interaction.kindedges 同理:它们描述网络,但真正解析「谁听得见谁」 的是你的 neighbors_fn

延伸阅读