一步,五个阶段¶
纵向模拟循环就是一个类: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 < 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),指标时间线上的
竖线标记就是它画出来的。
② 观察¶
读 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。
③ 决策¶
读观察结果与 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。
④ 应用¶
读整批动作。写 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):
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;
指标序列,连同对它触发过的干预:
每步按动作类型统计有多少 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() 就是那份声明式契约。
延伸阅读¶
- 概览 —— B = f(P, E)——循环为什么长成这样
- 干预与反事实分支——阶段 ① 携带一次干预时会发生 什么,以及分支如何重放
- 多轮交互——
interaction_rounds > 1的完整说明 socioverse.engineAPI 参考——循环、builder、registry 与 memory 模块