跳转至

数据服务:人群池与事件信号

一个研究就是 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 的回答是在陈述 这次扫描,而不是在陈述这个池子。

标准流程六步

  1. 声明目标边缘分布——来自普查表、调查微观数据或领域知识。
  2. 用 match_user_pool_fields 路由到最匹配的一个或多个池。用 value_probes 探一遍确切的类别标签:各池的标签词表并不一致,而抽样是按字符串精确匹配的, 所以一个拼错的标签会悄悄把整个分布格退化成 VIRTUAL_*。
  3. 从锚定池抽样,锚定池缺的维度则从次级池按二者共有的键做统计匹配来补齐 (例如按锚定 persona 的 AGE × GENDER 去过滤补充池)。
  4. 用 IPF 对齐——sampling_mode="preserve_joint" 会把池子里观察到的联合分布 重新加权到你声明的边缘分布上,并回报 achieved_marginals、 unsupported_targets、joint_support 与 ipf_iterations。默认的 sampling_mode="independent" 各字段独立抽取,不保留字段间相关性。
  5. 合成所有池都没覆盖的部分,以每个 persona 已有的人口学属性为条件,并记录 每个属性各自的来源。
  6. 组装 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 从不调用它。

标准流程六步

  1. 声明时间跨度,写成一串 "YYYY-MM",并选定数据源。
  2. 列一遍目录,确认每个选中源的覆盖范围与字段。
  3. 逐月抓取,让每个月的载荷只包含截至当月已经发布的内容。
  4. 给每份载荷盖上溯源戳——源、序列、数据时点(vintage)。
  5. 把盖好戳的载荷物化进研究工作区,成为一个 external-events bundle。
  6. 把 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")或有锚定的合成,并在溯源 里注明当时人群池不可用。

在托管服务上,两者都已预先配置好,并按你的额度计量。

延伸阅读