数据服务:人群池与事件信号¶
一个研究就是 B = f(P, E)。这四个符号里有两个必须来自真实世界:P——固定
人群,以及 E 的外生部分——模拟推进过程中世界送进来的信号。二者都不是框架
生成的,而是在构建期由两个类型化的 MCP 服务解析出来:
| 服务 | 解析什么 | 正式名 | 能力 / MCP server id |
|---|---|---|---|
| 人群池服务 | P —— 带持久 id 的 personas |
sv-user-pool |
user_pool_survey_mcp |
| 事件信号服务 | E 的外生通道 |
sv-event-tool |
event_tool |
本页讲的是这两个服务做什么、以及它们在磁盘上留下什么。注册、可用性检查、
confirm/cost 计量与降级链在外部能力;记录一个研究实际
用过什么的台账在真实世界锚定与溯源。
为什么这两个服务放在框架之外¶
把它们留在运行时包之外,直接带来三个性质:
- P 和 E 可以各自独立替换。 一个版本可以只换人群来源而不碰环境,也可以只 重新锚定环境而不重建人群——因为二者都没有被编译进引擎,各自只是一次服务调用 产出的、落在磁盘上的 bundle。
- 一套类型化接口,两类调用方。 同一组工具既服务于会话内的
sv-*skills,也 服务于任何会说 MCP 的外部 agent。不存在 skills 专用、外部拿不到的私有后门。 - 第三方可以实现兼容服务。 框架依赖的是返回内容的形状(带
agent_id的 记录;逐月的SourceResult信封),而不是这两台特定服务器。把SV_USER_POOL_MCP_URL指向你自己的人群池服务,socioverse/里一行都不用改。
服务本身是闭源的、部署在服务端。本地 clone 通过带鉴权的 HTTP 访问它们,或者 降级——见从本地 clone 接入这两个服务。
人群池服务¶
注册了什么¶
服务的 config/platforms.yaml 里注册了 7 个 platform id,覆盖 5 个子池(两个内部
池各注册了一个原始变体和一个预处理变体):
| 子池 | platform id | 源池记录数 | profile 字段数 | 角色 |
|---|---|---|---|---|
| 内部 X/Twitter | x、x_mode_1 |
973,928 | 10 | 锚定池;真实行为文本,英文 prompt |
| 内部小红书 | rednote、rednote_v2 |
9,158,404 | 8(v2) | 锚定池;中文语境 |
| Nemotron-Personas USA | nemotron_usa |
59,994 | 7 | 锚定 / 补充池;与普查对齐,完全开放 |
| MatrAIx Persona 1M | matraix_1m |
56,583 | 20 | 补充池;高维,使用条款受限 |
| PersonaHub | personahub |
199,466 | 0 | 只用于检索与 persona 文本素材 |
每个条目声明 profile_fields(能拿来过滤的字段)、numeric_fields、
simulation.supported_fields(其中可以当 distributions 键用的子集)、
simulation.prompt_language,以及一个 attribution 块。x_mode_1 与
rednote_v2 是分布模拟期望使用的预处理变体。
上表里的数字是注册源池的规模,不是 SQLite 索引里的条数。某个部署实际索引了每个
池的多少、以及哪些平台压根没建索引,以 get_user_pool_index_status 的回报为准:
开源池通常整池索引,两个大体量的内部池则通过预处理变体按切片索引。
PersonaHub 不带任何人口学字段
它的 profile_fields 为空是设计如此:那是一份纯文本语料。它可以支撑
search_user_pool 并提供 persona 文本,但不能支撑分布抽样或跨池统计匹配。
matraix_1m 只能校准一维边缘分布,且未声明许可证——两者都属于研究/演示层级,
注册条目的 usage_restriction 里写明了这一点。
十个类型化工具,四组¶
| 组 | 工具 | 返回什么 | 关键参数 |
|---|---|---|---|
| discovery | list_user_pool_platforms |
已注册平台、源文件数与体积、字段配置、索引状态 | — |
| discovery | describe_user_pool_platform |
单个平台;只读第一个源文件的第一条记录作为 schema 自检 | platform_id、include_sample_content |
| discovery | get_user_pool_field_values |
某字段的高频取值,用于词表对齐 | platform_id、field、use_index、limit ≤ 200 |
| routing | match_user_pool_fields |
针对一组请求字段给出逐平台覆盖度,外加一份 suggestion:指定锚定池、为每个未覆盖字段指定补充池,并列出 unresolved_fields |
fields、include_values、values_limit、value_probes |
| retrieval | search_user_pool |
按文本、精确 profile 过滤与数值区间检索 personas | platform_id、query、profile_filters、numeric_filters、limit ≤ 100、offset、扫描上限 |
| retrieval | get_user_profile |
按 user_id 取单个 persona |
platform_id、user_id、include_sample_content |
| simulation | get_user_pool_simulation_capabilities |
哪些平台已具备抽样能力、其 supported fields、prompt 语言、服务端模型、鉴权模式 | — |
| simulation | run_user_pool_distribution_simulation |
抽样出的受访者 + 每人一条 LLM 问卷作答,原始 JSONL | platform_id、distributions、user_count(1–100,000)、questionnaire、seed、max_workers ≤ 128、allow_virtual_users、sampling_mode |
| index | get_user_pool_index_status |
索引是否存在、SQLite 体积、FTS 可用性、逐平台已索引条数 | — |
| index | build_user_pool_index |
构建或刷新 SQLite 索引 | platform_id、rebuild、max_records(默认 50,000;null = 不设上限)、batch_size、include_full_content |
十个工具里有八个标注为只读。run_user_pool_distribution_simulation 是计费的那一个
——它按 user_count 成比例消耗 LLM 预算;build_user_pool_index 会写索引。
match_user_pool_fields 既只读又不计费,所以路由这一步理应排在一切动作之前。
真正干活的东西都跑在 SQLite 索引上
分布抽样要求索引存在,绝不会去预读源 JSON:平台没建索引时它不会降级,而是
直接返回 status: "not_indexed",要求你先把索引建起来。会降级的是查询类工具——
search_user_pool、get_user_profile 与 get_user_pool_field_values 在无索引时
退化为一次有界扫描,上限由 max_scan_records / max_scan_files /
max_scan_bytes 控制,响应会被标上 partial: true。一个 partial 的回答是在陈述
这次扫描,而不是在陈述这个池子。
标准流程六步¶
- 声明目标边缘分布——来自普查表、调查微观数据或领域知识。
- 用
match_user_pool_fields路由到最匹配的一个或多个池。用value_probes探一遍确切的类别标签:各池的标签词表并不一致,而抽样是按字符串精确匹配的, 所以一个拼错的标签会悄悄把整个分布格退化成VIRTUAL_*。 - 从锚定池抽样,锚定池缺的维度则从次级池按二者共有的键做统计匹配来补齐
(例如按锚定 persona 的
AGE×GENDER去过滤补充池)。 - 用 IPF 对齐——
sampling_mode="preserve_joint"会把池子里观察到的联合分布 重新加权到你声明的边缘分布上,并回报achieved_marginals、unsupported_targets、joint_support与ipf_iterations。默认的sampling_mode="independent"各字段独立抽取,不保留字段间相关性。 - 合成所有池都没覆盖的部分,以每个 persona 已有的人口学属性为条件,并记录 每个属性各自的来源。
- 组装 persona:人口学标签、一段自然语言画像,以及该研究特有的初始状态。
allow_virtual_users 默认为 true
当请求的某个分布格在索引里找不到匹配时,服务会用明确标注的 VIRTUAL_* 受访者
把它填满,好让交付出来的边缘分布仍然符合你的要求。传
allow_virtual_users=false 可以改为如实报告这个缺口而不是填上它;无论哪种做法
都要读一遍返回的 sample。一个覆盖缺口本身就是关于你这个人群的发现。
确定性,以及面板主键为什么跨版本稳定¶
P 是在初始化时一次性物化的,不是运行中惰性生成的。/sv-build-population
调用的正是引擎在 t = 0 会调用的那个 population.build(seed),在研究声明的 seed
之下执行并把结果写出。重跑这个研究会复现出同一批 agent,因此 agent_id 在各版本
与各分支之间都是面板稳定的主键——正是这一点,让两个版本能逐 agent 对照,而不是
只能在总量层面比较。
研究者自带的人群是同等一流的公民,规则完全一样:一个研究可以从上传的文件、或
从研究自定义的 builder 里取 P,「在一个 seed 下一次性物化」这条规则不变。
| provider_ref | 类 | provider_args |
|---|---|---|
file.personas |
FilePopulationProvider |
{"path": …, "format"?: "parquet"} —— CSV/Parquet,必须有 agent_id 列;group_key / weight 可选,其余列成为属性 |
mcp.socioverse_pool |
McpPopulationProvider |
{"server": <resources.json 里的名字>, "query": …} —— 需要构建 skill 注入一个执行该工具调用的 fetch_fn |
两者都在 socioverse/providers.py,产出 Persona 对象
(socioverse/schemas/population.py)。
落盘成什么¶
| 路径 | 内容 |
|---|---|
studies/<id>/population/population.json |
PopulationBundle —— study_id、provider_ref、provider_args、interaction、propagation、materialized_count;池子规模不大(≲1000)时 personas 直接内联 |
studies/<id>/population/roster.jsonl |
物化后的 t = 0 名册,一行一个 agent;大池子以它为准,dashboard 读的也是它 |
studies/<id>/resources.json |
ResourceManifest.mcp_servers 按名字钉住服务(McpServerDecl)。声明里还有一个 url 字段,但构建 skill 只在端点确实公开时才填它,否则地址留在 .env / .mcp.json;key 绝不写进研究工件 |
studies/<id>/grounding/grounding.json |
每条承重分布一条事实,source.via = "provider",外加该池的 attribution 块 |
事件信号服务¶
数据源目录¶
21 个 collector,每个都是同一套接口(collectors/base.py)背后的独立类,注册在
COLLECTOR_REGISTRY 里。其中 10 个默认启用;sources="default" 精确选中这 10 个,
sources="all" 选中全部 21 个。
| 分类(代码里的 category) | 数据源 | 默认 |
|---|---|---|
macro_market |
fred、bls、treasury、nyfed_sce、yahoo_market、alt_fng、credit_spreads、housing_finance、ustr、nyt_archive |
十个全部启用 |
population |
census_acs、cps_basic、cps_asec、sipp |
关闭 |
optional |
world_bank、google_trends、gdelt、coingecko、stooq_market、news_alt、household_pulse |
关闭 |
子序列过滤(series="UNRATE,CPIAUCSL")由 fred、bls、credit_spreads、
housing_finance 与 world_bank 实现;其它 collector 忽略该参数,返回完整载荷。
world_bank 是非美国场景的接口:六个经济体(USA、CHN、JPN、IND、DEU、BRA)外加
世界总计(WLD)的年度 GDP 增速、CPI 通胀、失业率、总人口与人均 GDP,CC-BY-4.0,
署名信息随结果的 metadata 一起返回。
新增一个数据源 = 一个 collector 类加一行注册。平台其余部分一概不动。
三个检索工具,外加一条搜索路由¶
| 工具 | 签名 | 说明 |
|---|---|---|
list_sources() |
— | 全部 21 个源的 name、category、description、requires_api_key、api_key_env_var、default_enabled |
get_data(sources, year, month, use_cache=True) |
sources = 逗号分隔的名字、"default" 或 "all" |
某个源失败会变成 errors 里的一条记录,绝不抛异常 |
get_source_detail(source, year, month, series="", use_cache=True) |
单个源,可选子序列过滤 | SocioVerse2 客户端(socioverse.external_events)在给定 sources= 时调用的就是它 |
search_web(query, max_results=8, days=7, lang="") |
provider 链,先可用者胜:Tavily → Google News RSS → GDELT DOC | 实时搜索,不是按时点的查询 |
search_web 存在的理由是:模型侧的 web search 并不总是可达。它走的是部署方控制的
key,响应里会写明实际用了哪个 provider,结果在 grounding 台账里以
via="event_service" 引用。它不属于 point-in-time 路径:它回答的是「现在大家在
说什么」,而不是「在第 m 个月之前发表了什么」。ExternalEventsClient 从不调用它。
标准流程六步¶
- 声明时间跨度,写成一串
"YYYY-MM",并选定数据源。 - 列一遍目录,确认每个选中源的覆盖范围与字段。
- 逐月抓取,让每个月的载荷只包含截至当月已经发布的内容。
- 给每份载荷盖上溯源戳——源、序列、数据时点(vintage)。
- 把盖好戳的载荷物化进研究工作区,成为一个 external-events bundle。
- 把 bundle 翻译成
E的两个通道:信息层上的广播与物理层上的定时事件,各自 绑定到自己那个月对应的步。
第 2–4 步发生在服务内部,第 5–6 步发生在 /sv-build-environment 里。
解析发生在构建期。运行时不抓取任何东西。
socioverse/engine/loop.py 里的循环只读工作区,永远不会去够一个服务。这正是
一次运行自包含、可复现的原因,也是一个分支——它会重放父版本直到自己
fork_step 之前的那些步——能够与父版本逐步对照、而不是去对照一份被重新解析过
的世界快照的原因。
客户端:socioverse/external_events.py¶
from socioverse import materialize_external_events, month_range
path, ev = materialize_external_events(
study_dir,
query="US inflation and consumer sentiment, 2024", # provenance only
months=month_range("2024-01", "2024-12"), # REQUIRED
sources=["fred", "nyt_archive"],
)
month_range(start, end) 生成一个闭区间的 "YYYY-MM" 列表,长度一旦超过 48 就停下
(最多 49 项),所以超过四年的跨度要分批抓。materialize_external_events(...) 写出
environment/external_events.json 并返回 (path, bundle)。
ExternalEventsClient 沿三层链条依次尝试,并且从不抛异常:remote(已部署的
服务,SV_EVENT_API_URL + SV_EVENT_API_KEY,任何抓取之前先在
<scheme>://<host>/healthz 做健康探针)→ local_cache(SV_EVENT_LOCAL_CACHE,
按 <source>/<YYYY-MM>.json 布局)→ unavailable。timeout 是按源-月计的:
remote 层对每个源各调一次 get_source_detail,因此某个上游慢只会丢掉它自己那个
键,不会把整批拖住。
remote 层上 months= 是必填的
已部署的服务没有 query 解析器——agent 层是被刻意去掉的,好让 E 的数据通路上
不坐着一个不受控的 LLM。没有显式的 months=,_fetch_remote 会在健康探针之后、
任何一次工具调用之前抛错;fetch() 把它记成 remote 层的一条错误并落到缓存层,
所以你看到的是一个空 bundle,而不是一个异常。query 只为溯源而携带。
bundle¶
ExternalEventsBundle 的字段,也就是 environment/external_events.json 里看到的
那些:
| 字段 | 内容 |
|---|---|
query、requested_months、requested_sources |
请求了什么 |
provider |
"remote" | "local_cache" | "unavailable";.available 是属性 provider != "unavailable" |
endpoint、fetched_at |
哪个服务应答的、什么时候 |
results |
载荷本体,键为 "<source>_<YYYY-MM>" |
availability |
sources_used、sources_unavailable,以及逐键的 source_status |
errors |
逐层与逐「源-月」的失败记录 |
summary、web_evidence |
遗留字段——绝不要据此撰写内容 |
一条真实条目(节选,来自一个抓取了 2024–2026 年 fred 的研究;落盘的条目还带
description、fetched_at、error_message,以及全部十三条 FRED 序列,而不只是这里
列出的四条):
"fred_2024-01": {
"source_name": "fred", "category": "macro_market",
"target_date": "2024-01", "status": "ok", "from_cache": true,
"data": {
"snapshot": {"UNRATE": 3.7, "CPIAUCSL": 309.698, "UMCSENT": 79.0, "SP500": 4845.65},
"yoy": {"UNRATE": 5.71, "CPIAUCSL": 3.09, "UMCSENT": 21.73, "SP500": 18.86}
},
"metadata": {}
}
广播要从 ev.results 写,绝不要从 ev.summary 写
summary 与 web_evidence 承载的是另一个模型写的散文。把那些文字注入 E,
意味着你观察到的行为有一部分是在回应第三方 LLM 对世界的框定、而不是在回应世界
本身,这会污染 B = f(P, E) 里的归因。把类型化的数字从 results 里读出来,
广播那句话自己写。
把 bundle 翻译成 E¶
bundle 是证据,不是环境。第 6 步把它变成 socioverse/schemas/environment.py 里的
两种类型化对象,二者都绑定到某一步:
| 通道 | 类 | 字段 | 何时生效 |
|---|---|---|---|
| 信息 | Broadcast |
message_id、content、channel、at_step、ttl、audience("all" → 宏观,selector dict → 局部)、note |
自 at_step 起可见 ttl 步 |
| 物理 | ScheduledEvent |
at_step、target_layer、op(set/add/multiply/add_pct/replace_source)、selector、property_name、value、note |
在 at_step 的外生更新阶段,早于任何人观察 |
在本文档的词表里,两者都是干预;写 bundle 时两者都会被校验:target_layer 不
是已声明层的 ScheduledEvent 会被拒绝,没有任何 modality="information" 层时广播
也会被拒绝。
| 路径 | 内容 |
|---|---|
studies/<id>/environment/external_events.json |
上面那个 bundle —— 证据 |
studies/<id>/environment/environment.json |
EnvironmentBundle —— study_id、layers、scheduled_events、information_program.broadcasts、provider_ref、provider_args |
studies/<id>/trajectory/study.duckdb |
表 events(step INTEGER, note VARCHAR) —— 运行中真正触发过的干预 |
point-in-time 检索¶
每个检索工具都由 (year, month) 参数化,每个 collector 都把自己的观测窗截到那个月
的最后一天——比如 fred 请求的是 observation_end = date(year, month, last_day),
同比数字也按同一个截断口径计算。因此 2024-01 的载荷里不会有任何日期晚于 2024 年
1 月的观测值。
载荷按数据时点归档:FileCache 写到
<cache_dir>/<source>/<YYYY-MM>.json,与 socioverse.external_events 的 SV_EVENT_LOCAL_CACHE 层读取的
布局完全一致,所以离线重建一个研究时复现出来的是逐月的那串序列,而不是今天的数字。
任何回测或 nowcast 都仰赖这一点。如果第 t 步的模拟能看到晚于 t 所代表的真实日期
才发布的数字,那么结果度量的是这个泄漏,而不是这个模型。有两条推论值得写明:
- 一个延长了时间跨度的版本,按同样的 as-of 规则去抓新增的月份;它不会拿今天的数据 去重新解析旧月份。
- 后加的干预——比如某个分支里为第
m月声明的一条广播——由与父版本相同的 as-of 查询来锚定,因此分支被约束在与它所对照的那次运行同样的信息纪律之下。
as-of 是按观测日期给的保证
collector 实现的保证落在观测窗上:任何日期晚于请求月份的内容都不会进入载荷。
某个数字后来是否被修订,取决于上游 API 自己的修订政策。把你用的那个数据时点
(target_date、fetched_at、from_cache)记下来,无论哪种情况结果都仍然可审计。
治理¶
隐私。 人群池服务对外暴露的是人口学标签与统计画像。平台原始内容与可识别个人的
信息不在工具面之内——索引里存的是截断后的 sample_content 预览而非全文,即便
include_sample_content=true 也受 max_scan_bytes 约束。所有访问都走类型化接口。
署名与使用层级。 每个注册的池、每个信号源都带一个 attribution 块。开放池声明
project、author、license、url 与 citation;受限池还要额外声明
usage_restriction 与 caveats——personahub 是 CC-BY-NC-SA-4.0 且要求相同方式
共享,因此仅限研究与演示用途;matraix_1m 的数据集卡片未声明任何许可证,按同样
方式对待,任何付费或对外发表的运行之前都要逐研究做一次明确确认。attribution 块
会随每一次 simulation 响应一并返回,构建 skill 把它拷进 grounding/grounding.json。
报告自带数据基础。 /sv-report 会把 grounding 台账渲染成报告的「数据基础与参考
来源」一节,结论所依赖的池与信号时点就印在结果旁边。
可审计。 每个阶段写出一份经校验的产物;resources.json 声明这个研究依赖的服务、
数据集与工具;versions.json 记录每一次修改;events 表记录每一次真正触发的干预。
一个结论可以经轨迹存储回溯到产生它的环境包、人群包与干预时间表,再经资源清单回溯
到这些东西背后的池与数据时点。
端到端:2024 年美国消费者信心¶
1 —— 声明时间跨度与数据源。 12 个月度步,一步一个月,指标序列取 fred,新闻
框定取 nyt_archive。
2 —— 核对目录。 list_sources() 确认两个源都已注册,且 fred 在服务端需要
FRED_API_KEY;fred 还支持子序列过滤,所以可以只拉 UMCSENT 与 CPIAUCSL。
3–5 —— 抓取、盖戳、物化。 一次调用把三件事都做了:
path, ev = materialize_external_events(
study_dir,
query="US consumer confidence 2024",
months=month_range("2024-01", "2024-12"),
sources=["fred", "nyt_archive"],
)
assert ev.available # else: local cache, or the manual path
ev.availability["sources_used"] # ['fred', 'nyt_archive']
此时 environment/external_events.json 里有 24 个键,从 fred_2024-01 到
nyt_archive_2024-12。
6 —— 翻译成 E。 把类型化的数字读出来,每月写一条广播,绑定到那个月对应的步:
Broadcast(
message_id="macro_2024_01",
content="Consumer sentiment reads 79.0 this month, up 21.7% from a year ago. "
"Headline CPI is 3.1% year over year and mortgage rates sit at 6.69%.",
channel="news", at_step=0, ttl=1, audience="all",
note="fred_2024-01: UMCSENT/CPIAUCSL yoy/MORTGAGE30US",
)
十二条这样的广播进入 information_program.broadcasts,环境包声明一个
modality="information" 层来承载它们,每个承重数字都得到一条 via="event_service"
并带 as_of 月份的 grounding 事实。运行时不需要别的:第 0 步的外生更新阶段让
macro_2024_01 可见,所有 agent 观察到同一个状态,那一步的面板行记录下每个 persona
对此做了什么。
→ 按这个形状落地的随仓库研究是消费者信心。
从本地 clone 接入这两个服务¶
两个服务都部署在服务端,不属于开源包的一部分。clone 通过声明接入它们,而不是把它们 vendored 进来:
# .env (gitignored — the single source)
SV_EVENT_API_URL=http://your-event-host:9997/event_mcp # FULL MCP endpoint, path included
SV_EVENT_API_KEY=…
SV_USER_POOL_MCP_URL=http://your-pool-host:9998/user_pool_mcp
SV_USER_POOL_MCP_KEY=… # sent as Authorization: Bearer
python scripts/gen_mcp_json.py # renders .mcp.json from .env — never hand-edit it
没有凭据时,不会有任何东西崩掉,也不会有任何东西被悄悄伪造:
- 事件客户端落到
SV_EVENT_LOCAL_CACHE,再落到provider="unavailable";到那一步, 构建阶段改为手工检索网络,并把每个数字连同 url 与访问日期以via="web_search"记录下来; - 人群阶段回落到上传的文件(
provider_ref="file.personas")或有锚定的合成,并在溯源 里注明当时人群池不可用。
在托管服务上,两者都已预先配置好,并按你的额度计量。
延伸阅读¶
- 外部能力——注册条目格式、
confirm与cost的区别、完整降级链 - 真实世界锚定与溯源——这两个服务产出的事实是怎么被记录的
socioverse.external_events参考——自动生成的 API 文档- 额度与计费——托管服务上一次计费调用的开销