跳转至

一步,五个阶段

纵向模拟循环就是一个类:socioverse/engine/loop.py 里的 LongitudinalSimulator。它的 run() 方法就是整个运行时——socioverse/ 里其它所有东西,存在的意义都是被它调用。这一页把循环的一步拆到函数调用、 类型化对象、以及它们在存储里留下的行这个粒度。

一步到底算了什么

一步把环境推进一个 time_unit 单位,并且恰好为每个 agent 产出一个行为。 写开来是:

e_t^i   = ω(E_t, p_i)                 # observe: project the world onto agent i
b_{i,t} = f(p_i, e_t^i, m_{i,t})      # decide:  one typed action per agent
B_t     = { b_{i,t} }_{i=1..N}        # the step's collective behaviour
E_{t+1} = g(E_t, B_t)                 # apply:   fold the behaviours back in

ω 是观察算子,f 是行为函数,g 是环境转移,m_{i,t} = μ(b_{i,t−W:t−1}) 是 agent 对自己最近 W 个动作的有界记忆。每个符号都对应 socioverse/abc/ 四个接口 之一上的一个方法:

符号 接口方法 谁来实现
P PopulationProvider.build(seed) -> list[Persona] 研究自己的 population provider
E_0 EnvironmentProvider.reset(seed) 研究自己的 environment provider
外生变化 EnvironmentProvider.advance_to(t) -> list[ScheduledEvent] 同上
ω EnvironmentProvider.observe_batch(agent_ids, t, round_idx) -> list[Observation] 同上
f DecisionModel.decide_batch(obs, memories) -> list[Action] 研究自己的 decision model
g EnvironmentProvider.apply(actions) -> None 研究自己的 environment provider
面板行里的状态 EnvironmentProvider.agent_state(agent_id) -> dict 同上
指标行 MetricCollector.collect(env, actions, t) -> dict 研究自己的 collector
持久化 TrajectoryStore.record / record_metrics / record_events DuckDbTrajectoryStore

引擎从不伸手进 provider 内部。它只按固定顺序调这几个方法,然后把它们返回的 东西写下去。

第 0 步不是一步

def materialize_initial(self) -> tuple[list[Persona], list[TrajectoryRecord]]:
    personas = self.population.build(self.seed)
    self.env.reset(self.seed)
    panel0 = self._panel_rows(personas, actions=None, t=0)
    return personas, panel0

run() 开场就是 materialize_initial()。它物化固定人群 P——持久、确定、 永不复用的 agent_id——构建 E_0,并在 t = 0 为每个 agent 写一行 action_kind = NULL 的面板。第 0 步不调用任何 decision model,不花一个 LLM token。 同时也收集一行第 0 步的指标,因此每条指标序列都从运行前的基线 开始。

有两个后果值得记住:

  • 对确定性 provider 而言,这个方法是 seed 的纯函数。正因为如此, sv-build-population 才能调用模块级的同源函数 socioverse.engine.builder.materialize_initial(env_bundle, pop_bundle, seed), 在任何一次运行之前就写出 population/roster.jsonl。你在构建期检查的那份 roster,与运行真正起步的 t = 0 逐字节一致。
  • n_steps: 12 的一条轨迹,每个 agent 有 13 行面板(第 0…12 步)。

materialize_initial() 一返回,run() 就为每个 agent 分配一个 AgentMemory ——这发生在存储被打开之前——然后进入循环。分支进入循环的时点更晚: _replay_inherited 先从父版本存下来的动作里把 1 … t*−1 步重新推一遍,真正开始 决策的第一步是分叉步 t*。每个真正计算的步都按这个顺序跑下面五个阶段:

flowchart LR
    A["① 外生更新<br/>env.advance_to(t)"] --> B["② 观察<br/>env.observe_batch(ids, t, r)"]
    B --> C["③ 决策<br/>decision.decide_batch(obs, memories)"]
    C --> D["④ 应用<br/>env.apply(actions)"]
    D -->|"r &lt; interaction_rounds-1"| B
    D --> E["⑤ 记录<br/>memory · panel · metrics"]

① 外生更新

fired = self.env.advance_to(t)                     # ① exogenous update
if fired:
    self.store.record_events(t, [self._event_note(e) for e in fired])

读环境包里的 scheduled_events 与 information_program.broadcasts (environment/environment.json)。写 provider 自己的层状态,以及为每个被 触发的条目往 events 表里写一行。

这是唯一一个不靠 agent 做任何事就改变 E 的阶段,而且它跑在任何人观察 之前——所以第 t 步里所有 agent 面对的是同一个世界状态。一次干预无非就是 在这里触发的一个条目:可以是 ScheduledEvent(对某个已声明的物理层的选定部分 做类型化的数值变更——set、add、multiply、add_pct、replace_source), 也可以是 Broadcast(带 channel、ttl 和 audience 的信息内容,audience 要么是 "all",要么是一个 selector 字典)。详见 干预与反事实分支。

返回的列表里装什么,由 provider 决定。引擎只负责把回来的东西记下来,用 ev.note or f"{ev.op} {ev.property_name or ''}".strip() 把每一条变成一句 note。 想让广播也进 events 表的 provider,会把它包进一个携带 note 的 ScheduledEvent, Chicago adapter 就是这么做的:

newly = self.info_env.advance_to(t)                # InformationEnvironment
for b in newly:
    fired.append(ScheduledEvent(at_step=t, target_layer="information",
                                op="set", note=f"broadcast: {b.content}"))

同一批 note 会被拼进这一步指标行的 events 列(_collect),指标时间线上的 竖线标记就是它画出来的。

② 观察

obs = self.env.observe_batch(agent_ids, t, r)      # ② observe

读 provider 的层状态,加上每个 agent 的位置、网络与属性。什么都不写—— 它返回 list[Observation],每个 agent 一条,引擎原样交给 decision model。

一条 Observation(socioverse/schemas/runtime.py)就是模态 × 尺度的四象限视图:

字段 模态 × 尺度 典型内容
macro_physical 物理 × 宏观 全市空置率、指数水平、天气
local_physical 物理 × 局部 自己所在 tract 的租金与构成、邻居的立场
macro_information 信息 × 宏观 一条 audience="all" 的广播
local_information 信息 × 局部 限定受众的通知、邻居的发帖

再加上 agent_id、step,以及一个可选的 rendered 字符串——预渲染好的 prompt 片段,省得 LLM decision model 对每个 agent 都把同样的 dict 再序列化一遍。 Observation.quadrants_nonempty() 返回哪些象限真的有内容,这是审计"研究的信息 层到底有没有送到人手里"最快的办法。

三个可复用的层辅助件住在 socioverse/env_layers/,由 provider 自己组合—— 引擎从不调它们:

  • InformationEnvironment(information.py)——按 at_step/ttl 激活与过期 广播,并渲染成 macro() / local_for(agent_ctx) / rendered_lines(agent_ctx)。 局部受众由一个 matcher 解析,默认是 match_selector,它认得 <attr>、<attr>_in、<attr>_below、<attr>_above、geoid_list、 index_below 这些 selector 键。空 selector 谁也不匹配。
  • NeighborFeed(neighbor_feed.py)——把 post / reply 动作变成别人的 local_information,经由一个 MessageBus 和一个 neighbors_fn 中转,而不是 O(N²) 的直接调用。
  • news_broadcast.py——default_information_layers() 以及 macro_news(...)、 local_notice(...) 两个构造器。全市公告和一条辖区通知是同一个机制,差别只在 audience。

③ 决策

actions = self.decision.decide_batch(obs, self.memories)   # ③ decide: B_t = f(P, E_t)

读观察结果与 memories(dict[agent_id, AgentMemory])。什么都不写—— 它返回 list[Action],也就是这一步的 B_t。

这个接口刻意是 batch 优先的:decide_batch 一次看到全部 agent,所以实现可以按 cohort 分组、每个 archetype 发一次 LLM 调用、或者用线程池并发跑逐 agent 的 prompt。引擎从不逐个 agent 地调用模型。随仓库提供的从零模板用的是线程池——每个 agent 一次 LLM 调用、并发发出——绝不让一条 JSON 回复覆盖全部人群;遗留适配器则保留其引擎 自己的方式。(DecisionModel.decide() 只是单 agent 的 便利封装,内部还是走 decide_batch。)

没有任何东西强制实现必须为每个 agent 都返回一个动作。这一步没有动作的 agent, 面板行的 action_kind 就是 NULL——重放也正是这样还原一个没动的 agent。

④ 应用

self.env.apply(actions)                            # ④ apply: E_{t+1} = g(E_t, B_t)

读整批动作。写 provider 的层状态——这次调用就是 g。

引擎把完整的 B_t 一次性交给 apply,每轮只调一次,所以这一轮是同步结算的: 没有 agent 能在自己的决策里看到别人的动作,排在列表前面也占不到便宜。provider 可以在这里解决争用——Chicago adapter 就按种族施加了一个搬迁配额,然后把"这次搬迁 到底有没有执行"标回每个动作的 payload:

a.payload["moved"] = bool(agent.moved_this_step)
if agent.moved_this_step:
    a.payload["new_tract"] = agent.tract_id

这次改写发生在面板行构建之前,所以面板记下的是兑现的结果,而不只是意图。

对从零构建(Path B)的研究,apply 应当是传给它的那批动作的纯函数。正是这 一条性质让重放成为可能——从而也让分支成为可能。

⑤ 记录

for a in last_actions:                             # ⑤ record — memory window first
    self.memories[a.agent_id].push(t, a)

panel_t = self._panel_rows(personas, last_actions, t)
self.store.record(panel_t)
self._emit_panel(panel_t, t)
m = self._collect(personas, last_actions, t, fired_notes=[...])

读每个 persona 的 env.agent_state(agent_id),加上这一步的动作。写,按这 个顺序:每个 agent 的记忆窗、每个 agent 一行面板、一行指标。

build_panel_rows(写成自由函数,所以运行前的物化器和循环产出的行形状完全一致) 这样构建每一条 TrajectoryRecord:

TrajectoryRecord(agent_id=p.agent_id, step=t,
                 state=env.agent_state(p.agent_id),
                 action_kind=a.kind if a else None,
                 action_payload=a.payload if a else {})

注意什么没进面板:rationale 和 source 只留在内存里的 Action 上,存储不 持久化它们。面板保留的恰好是 kind + payload——重放重建一个动作所需的最小集合。

_collect 调用 collector.collect(env, actions, t),强制 m["step"] = t,把触发 事件的 note 追加为 m["events"],把这一行交给 store.record_metrics,最后触发 每步心跳和可选的 on_step(t, metrics) 回调。

还有两个纯文本文件会作为副作用写在 DuckDB 存储旁边,两者都是 best-effort、绝不会 把一次运行搞挂:progress.jsonl(每步一行,在 t = 0 时清空重写)和 panel_live.jsonl(与面板表同样的行)。它们存在是因为运行期间 DuckDB 是单写者加 锁的,所以 dashboard 在跑批中途读的是这两个文件,而不是数据库。

Action 契约

class Action(BaseModel):
    agent_id: str
    step: int
    kind: str                                       # move | stay | post | reply | adopt_opinion | ...
    payload: dict[str, Any] = {}
    rationale: list[str] = []
    source: Literal["llm", "rule", "fallback", "replay"] = "llm"
  • kind——动作类型,一个由研究自己定义的字符串。环境按它分支 (studies/opinion_diffusion/model.py 里的 if a.kind == "update_opinion": ...); NeighborFeed.ingest 只把 "post" 和 "reply" 路由到消息总线上。
  • payload——该类型特有的内容:目的 tract、一个数、一段文本。必须可 JSON 序列化,它是以 JSON 列存下去的。
  • rationale——第一人称的理由字符串列表。审计一次 LLM 运行时很有用;不 持久化到面板。
  • source——恰好四个取值:
source 什么时候会是它
llm 模型回复了,且回复解析成功
rule 一条确定性的更新规则产生了这个动作
fallback 调了 LLM,但回复解析失败,于是由规则补上
replay 引擎从父版本的面板行里重建出了这个动作

把 fallback 与 rule 分开记,才能量出一次"LLM 运行"里到底有多少真是 LLM 干的。

正因为四种行为函数返回的都是这同一个类型化对象,环境与存储就与"决策是怎么产生 的"这件事解耦了。 无论 f 是一条规则、一个 prompt、一个训练出来的策略,还是一次 重放,apply、agent_state、面板 schema 和每一个指标都原封不动地工作——这也正是 规则 vs LLM 的对照之所以是受控对照的原因:两者在同一个离散动作空间里行动,被 记进同一张表。

四种行为函数 f

f 是研究的机制唯一的落脚处,而在循环内部它永远是一个 DecisionModel。从零构建 (Path B)的研究把它注册在一个字符串键下:

from socioverse.engine.registry import register

@register("decision", "opinion.decision")
class BoundedConfidenceDecision(DecisionModel):
    def __init__(self, confidence: float = 0.3, llm_kind: str = "scripted", ...):
        ...

simulation/simulation.json 写明这个键和它的构造参数:

{
  "decision_ref": "opinion.decision",
  "decision_args": {"confidence": 0.3, "llm_kind": "openai", "model": "gpt-4o-mini", "max_workers": 8}
}

build_simulator(socioverse/engine/builder.py)用 resolve("decision", sim_config.decision_ref)(**sim_config.decision_args) 把它解析 出来。registry 一共管六类——environment、population、decision、collector、 store、reporter——而且如果研究模块压根没被 import,解析会大声报错,因为 @register 是在 import 时执行的。

包装遗留模拟器的研究,在这一个组件上绕过 registry:它自己的 build_*_simulator 工厂直接把 decision model 构造出来(studies/chicago_schelling/adapter/build.py 里 的 decision = SchellingDecisionModel(engine)),这样好几个组件可以共享同一个可变 的遗留引擎。循环分辨不出差别——它拿到的反正是一个 DecisionModel。

f 做什么 怎么接上去
rule-f 求值一条经典更新规则;对齐用的基线 一个注册过的 DecisionModel,decide_batch 里跑算术,并打上 source="rule"
LLM-f 把 persona + 四个观察字段 + 记忆窗拼成 prompt,再把结构化回复解析成动作 通常就是同一个注册类,由 decision_args 的一个开关切换(llm_kind: "scripted" \| "openai"),这样规则始终留在那里当 fallback
RL-f 用训练好的策略把观察映射成动作,每类策略性行动者一份 经由研究 adapter 接入(studies/drug_procurement/adapter/,该研究不在开源版本中),adapter 跑外部训练器,并导出同构的 panel / metrics 表
hybrid-f 一次运行里不同 agent 群体用不同实现——典型做法是一小撮核心用 LLM-f,其余用 rule-f 一个注册类在 decide_batch 内部分流,例如 studies/hisim_roe/model.py 的 HybridTwitterDecision:self._decide_core(ob, ...) if is_core(ob.agent_id) else self._decide_ordinary(ob)

把 rule-f 与 LLM-f 配对藏在同一个 decision_ref 背后,是值得照抄的模式:只要回复 解析失败,LLM 那条路就回落到完全相同的规则算术,于是指标列永远是满的,两次运行天然 可比。

记忆窗

class AgentMemory:
    def __init__(self, agent_id: str, window: int = 8):
        self.history: deque[dict] = deque(maxlen=window)

    def push(self, t, action=None, observation_summary=None):
        self.history.append({"t": t, "action_kind": ..., "payload": ..., "summary": ...})

每个 agent 拥有一个有界滚动窗口,装的是它自己的动作——不是别人的,也不是环境的。 默认 8 步——也就是上面 m_{i,t} = μ(b_{i,t−W:t−1}) 里的 W——可以按研究用 engine_args.memory_window 配置,build_simulator 读的是 sim_config.engine_args.get("memory_window", 8):

{"engine_args": {"memory_window": 12}}

run() 在任何一步被计算之前就创建好这些窗口,并在阶段 ⑤ 往里 push。AgentMemory 还提供 recent(n)、last_action_kind() 和 count_action(kind),让 decision model 可以基于 agent 自己的过去做条件判断("我已经搬过两次了,这次不动"),而不必回头读存储。

窗口只由面板记下的那些动作填充,因此它可以从 panel 表精确重建。这正是分支 的前提:_replay_inherited 从父版本的面板行里重建出父版本的动作,把它们 apply 一遍, 再 push 进窗口,于是在分叉步上每个 agent 带着它在父运行里一模一样的记忆继续跑。

out.append(Action(agent_id=r["agent_id"], step=t, kind=kind,
                  payload=dict(r.get("action_payload") or {}), source="replay"))

interaction_rounds

for r in range(self.interaction_rounds):
    obs = self.env.observe_batch(agent_ids, t, r)
    actions = self.decision.decide_batch(obs, self.memories)
    self.env.apply(actions)
    last_actions = actions

interaction_rounds(默认 1,由 max(1, …) 钳位)是一步之内观察 → 决策 → 应用这个循环跑几遍。把它调大,agent 就能对别人在这一步里说的话做出反应; advance_to 仍然只在第 0 轮之前触发一次,而且轮不是时间。

只有最后一轮会被记录——所以多轮研究无法重放

记忆收到的是 last_actions,面板行携带的是最后一轮的 kind + payload。 第 0 … K−2 轮在轨迹里不留任何痕迹。这正是 _replay_inherited 在 interaction_rounds != 1 时拒绝运行的原因:

ValueError: branch replay supports interaction_rounds == 1 only (study has 3);
the parent panel keeps only the final round's actions, so multi-round steps
can't be replayed exactly — re-run from step 0.

(warm_start 是引擎里给分支重放规格起的字段名——SimulationConfig.warm_start, 一个 WarmStartSpec,它的 fork_step 等于 resume_from + 1。)因此多轮研究只能 作为新版本迭代,从第 0 步冷跑,而不能作为分支。参见 多轮交互 与 干预与反事实分支。

落盘成什么

DuckDbTrajectoryStore(socioverse/io/duckdb_store.py)每次运行写一个文件, studies/<id>/trajectory/study.duckdb。open() 会先删掉已存在的文件——一次运行总是 全新的;更早的结果活在版本快照里。

表 schema 谁写的 基数
panel agent_id VARCHAR, step INTEGER, state JSON, action_kind VARCHAR, action_payload JSON record(),逐步流式写 N × (T + 1) 行
metrics step 在最前,之后每个指标键一列;只要有任何一步触发过东西,就再多一列 events(其余步为 NULL) record_metrics() 先缓冲,finalize() 时物化成一张宽表 每步一行
events step INTEGER, note VARCHAR record_events(),流式写 每个被触发的事件或广播一行
messages author_id, step, round, channel, content, audience record_messages(),首次调用时才惰性建表 只有持久化了消息总线的研究才有

(agent_id, step) 唯一确定一行面板——这是纵向主键,但它由写入方式保证,并没有在 建表时声明成约束。finalize() 还会在数据库旁边导出 panel.parquet 与 metrics.parquet。

用任何 DuckDB 客户端都能查,从 Python 里也行:

from socioverse.io import open_readonly

con = open_readonly("studies/opinion_diffusion/trajectory/study.duckdb")

单个 agent 的完整轨迹——每一步的状态、动作和 payload:

SELECT step,
       state->>'opinion'           AS state_opinion,
       action_kind,
       action_payload->>'opinion'  AS decided_opinion
FROM panel
WHERE agent_id = 'od-007'
ORDER BY step;

指标序列,连同对它触发过的干预:

SELECT m.*, e.note
FROM metrics m
LEFT JOIN events e USING (step)
ORDER BY m.step;

每步按动作类型统计有多少 agent 动了:

SELECT step, action_kind, count(*) AS n
FROM panel
WHERE action_kind IS NOT NULL
GROUP BY step, action_kind
ORDER BY step, n DESC;

metrics 有哪些列,取决于研究自己的 MetricCollector.collect() 返回了什么—— opinion-diffusion 模板是 step, mean_opinion, opinion_std, frac_above_0_5, events—— 所以先 SELECT * 看一眼。研究如果愿意声明,collector.columns() 就是那份声明式契约。

延伸阅读