• v1.2.1 2af445cfc2

    iomgaa released this 2026-08-19 00:55:58 +08:00 | 129 commits to main since this release

    每次调用现在可以带上租户标识与任意调用方自定义维度,并逐条落进遥测表(issue #11)。llm_calls 存的是完整正文(digest_messages 只对多模态 image_url 做 sha256,纯文本原样透传),多租户下游的合同与标书全文因此混在同一张表里,而原先的 22 列没有任何租户维度——能区分来源的只有 session_id / parent_call_id 两个调用方自填、库内不校验的自由字符串。

    不可逆性是这个 issue 的核心论点,且成立: 先启用遥测再补列,补列之前写进去的每一行都没有归属,事后无法还原哪行属于谁。

    新增

    • 四个公共方法各增两个 keyword-only 参数 tenant_idmeta,都带默认值 None,既有调用点零改动: GatewayClient.chat()EmbeddingClient.embed()OcrClient.recognize_text()OcrClient.parse_layout()。issue 只诉求前两条链路;OCR 经同一个 TelemetryEmitter同一张表,只覆盖两条会让同表内一部分行有归属、一部分永远空白,故一并纳入(与 issue #10 同一判断)。
    • 遥测表 llm_calls 新增两列,排在既有 22 列末尾,两端类型按各自后端的原生能力取:
    Postgres SQLite
    tenant_id TEXT NOT NULL DEFAULT '' TEXT NOT NULL DEFAULT ''
    meta JSONB NOT NULL DEFAULT '{}'::jsonb TEXT NOT NULL DEFAULT '{}'
    • 老表经现有 _BACKFILL 机制自动补列(先探测再 ALTER,失败只逐行降级),补列后老行的 tenant_id 读出是空串而非 NULL。这个区别是刻意的: PG 的 RLS USING 表达式返回 false 或 null 的行都不可见、且静默跳过不报错,所以 NULL 的 tenant_id 在任何 policy 下都不是"未归属",而是对所有人永久不可见的黑洞;哨兵空串则显式可查,COUNT(*) WHERE tenant_id = '' 一条 SQL 就能审出还有多少行待归属。补列本身两端都不停机: PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作。
    • TelemetryRecorder.record_llm_call 由 22 字段扩为 24(inspect.signature 实测),ChatRequest 同步新增两个带默认值的字段。metajson.dumps(sort_keys=True, ensure_ascii=False, allow_nan=False) 序列化,空 dict 落 '{}' 而非 NULL。

    校验规则(超限报错,不静默丢弃)

    校验在四个公共入口收口、进洋葱之前抛裸 ValueError,四条链路共用同一份实现:

    规则
    tenant_id 长度 ≤ 128;不得含首尾空白;空串是哨兵值的地盘,调用方传空串多为 bug
    meta 键数 16
    meta 必须匹配 [a-z0-9_.]{1,64};pg_ 前缀保留给库将来的内建维度(本版库自身不写任何该前缀的键)
    meta str / int / float / bool,嵌套需调用方自行序列化;字符串值 ≤ 256 字符;float 必须有限,nan / inf 报错(它们不是合法 JSON,PG 的 JSONB 会拒收)

    报错点选在入口而非遥测写入点: 遥测层的一切失败都按降级方向铁律吞成 warning,校验放那里等于没有校验。超限一律报错,不采用"超长就丢弃"的做法——那违反 P5「严禁默认值掩盖错误」,会把调用方的输入错误转化成静默丢数据。

    不变

    • tenant_idmeta 都不进缓存 key。租户级的缓存隔离由既有的 cache_namespace 负责,重复进 key 只会让全部存量缓存冷启动;且 meta 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 batch_id 不应导致 miss。
    • 既有 22 列的列名与列序、ON CONFLICT (call_id) DO NOTHING 幂等、单条写失败逐行丢弃的降级方向全部未动。错误面零变更,下游 except 写法不受影响。
    • 缓存命中行与终态失败行同样带维度,且读的是本次 request 而不是缓存里的历史响应——这两类行恰恰是审计最需要的(命中意味着这次没花钱但确实发生了;终态失败意味着这个租户的请求没被服务)。

    边界: 库只交付列,RLS 与索引由下游执行

    库不会执行 ENABLE / FORCE ROW LEVEL SECURITY,也不会建任何索引。 需要数据库层的强制隔离,下游 DBA 必须自行执行 RLS DDL 与 CREATE POLICY(并建 (tenant_id, created_at) 复合索引——启用 RLS 后 policy 会给每条查询隐式追加 tenant_id 等值谓词,它必然是前导列);不执行则 tenant_id 只是一个可查、可过滤的普通列,没有任何数据库层强制

    不自动启用的首要理由是 default-deny: 启用 RLS 而无匹配 policy = 零行可写,且静默不报错。三个下游里只有一个是多租户,库若自动启用,其余部署升级后遥测全量写失败,再叠加遥测的静默降级铁律,就是无声全局丢数据——恰是本 issue 所担心的"不可逆"的最坏形态。其余理由: policy 必须绑定角色而库只拿到一条连接串;CREATE POLICY / ALTER TABLE 要求表属主,而按最佳实践部署时库的运行时角色恰好不是属主;SQLite 根本没有 RLS,承诺 RLS 会让两个后端语义不对等。

    RLS 模板与三个陷阱(表属主默认豁免 RLS 需 FORCE;租户上下文必须在显式事务内 set_config(..., true),asyncpg 默认 autocommit 下单发 SET LOCAL 会当场失效而 PG 只发 warning;只写 USING 不写 WITH CHECK 时租户 A 能插入标着 B 的行)见 README「多租户与自定义维度」一节——那份模板随包分发,research-wiki/ 不在 sdist 内。

    升级提示

    • 升级无需任何代码改动: 两个新参数都是带默认值的 keyword-only,既有调用点原样工作;不传即写入哨兵空串与空 {}
    • README 的安装 pin 由 >=1.2,<2 收紧为 >=1.2.1,<2。按 >=1.2,<2 装的下游不会被锁死(仍会拿到本版),但显式装 1.2.0 就没有租户维度
    • README 的配置参考表此前漏列了源级 MISSING_DONEEXTRA_BODY(正文别处却引用了后者)、{SCOPE}__QUOTA_FULL、embedding 专用键、PGW_CACHE_BACKENDmemory 档与三个可选 PGW_* 键,本版按 config.py_SOURCE_FIELDS_load_pgw 逐项补齐。代码零变更。
    Downloads