Files
EvoScientist-WebUI/docs/token统计方案.md
T
m4 fe982f7f95
CI / macos-latest / Node 20 (push) Has been cancelled
CI / ubuntu-latest / Node 20 (push) Has been cancelled
CI / windows-latest / Node 20 (push) Has been cancelled
feat: add workspace isolation and administration UI
2026-07-19 12:17:18 +08:00

70 KiB
Raw Blame History

EvoScientist-WebUI Token 统计方案

版本:2.0 | 日期:2026-07-16 | 所属项目:EvoScientist-WebUI 状态:阶段零至阶段二已实施并具备自动化验收;阶段三按当前 Provider 聚合能力实现差异报告,不伪造逐请求对账

1. 决策摘要

Token 统计的核心不是按对话、SSE 消息或最终回答统计,而是按每一次真实模型调用统计。

每次主代理、子代理、摘要器、Tool Selector、Memory Worker、Scheduler 和 fallback 实际调用模型时,都产生独立的 model_call_id 和 usage 记录。v1 只接收模型进程终态 callback 产生的 callback_final,不接收 partial、Gateway 或供应商对账事件,从源头避免同权威来源竞争。

2.0 采用以下项目边界:

  • EvoScientist-WebUI 是 Token 统计功能的所有者。
  • EvoScientist 只运行一个无业务聚合逻辑的轻量采集探针。
  • Token 事件接收、持久化、权威投影和聚合查询均位于 EvoScientist-WebUI。
  • WebUI 不从聊天文本或 SSE 累加 Token。
  • 没有供应商真实 usage 的调用标记为 unknown,不估算,也不计入已确认总量。
  • Agent scope 使用显式 usage_scope 和受测试的 metadata 映射,不依赖模糊推断。
  • 不新增用户、角色或 workspace 权限模型;Token 统计按当前 WebUI 的单用户部署边界运行。
  • 阶段零先冻结跨语言协议、共享 fixtures 和运行参数,再并行开发 Python sender 与 TypeScript Collector。
EvoScientist-WebUI 创建 turn_id
    -> LangGraph run metadata
    -> EvoScientist UsageCaptureCallback
    -> UsageEvent 上报
    -> EvoScientist-WebUI Usage Projector
    -> Token 查询和展示

2. 目标和非目标

2.1 目标

  1. 覆盖所有真实模型调用,而不只是主代理最终回答。
  2. 同一个模型调用重复上报时只统计一次。
  3. 按线程、回合、Agent scope、Provider 和模型聚合,并保证 scope 归类可验证。
  4. 原始 usage 观测与权威 Token 投影分离。
  5. 供应商 usage 对账修正保留审计轨迹。
  6. EvoScientist 的模型执行、流式输出和 Agent 行为不受统计故障影响。
  7. 第一阶段尽量减少对 EvoScientist 的修改范围。

2.2 非目标

第一阶段不处理:

  • 为所有 OpenAI-compatible 网关强制开启 stream_usage。
  • 基于 prompt 长度估算缺失 Token。
  • 修改现有 CLI/TUI 的临时 usage 展示。
  • 将所有 Provider 强制迁移到统一 LLM Gateway。
  • 将 Token 统计与 checkpoint 生命周期绑定。
  • 新增用户、角色、workspace ACL 或多租户权限控制。

3. 核心原则

3.1 一次真实调用一条记录

一次回合如果发生:

  • 2 次主代理调用
  • 3 次子代理调用
  • 1 次摘要调用
  • 2 次 fallback 尝试

则记录 8 个模型调用,再汇总 8 条最终 usage。不能只记录一条“本回合总 Token”。

3.2 使用 LangChain run_id 幂等

callback 中的 LangChain run_id 作为 model_call_id。v1 的 source=callback_final、revision=1 均为常量,同一个 callback 事件因网络重试重复上报时,接收端通过 deployment_id + model_call_id + callback_final + 1 幂等处理。

供应商返回的 provider_request_id 单独保存,用于后续供应商 usage 记录匹配,不能取代本地 model_call_id。

3.3 同一调用的版本不相加

v1 对每个模型调用只接受一个来源:

source = callback_final
authority_class = observed_final
  • 流式 partial usage 只在 callback 内存中缓存,用于构造终态事件,不单独上报。
  • Gateway usage 不进入 v1 Inbox,也不参与投影。
  • 供应商对账留到阶段三,由 WebUI 内部任务生成;普通 sender 和 /api/usage/events 不能提交对账来源。
  • 相同 event ID 出现不同 payload 时进入 conflict,投影保持原值,不能采用最后写入者。

如果未来需要多个观测来源,必须发布新的 schema version,并把来源策略作为不可变 projection_policy_version 保存;不能读取当前可变 Provider Profile 后重算历史。

3.4 unknown 不等于零

模型正常结束或触发 on_llm_error,但供应商没有返回 usage 时,记录:

usage_status = unknown

unknown 调用:

  • 不进入已确认 Token 总量。
  • 不作为零 Token 展示。
  • 进入待对账和健康检查统计。

如果 Worker 在终态 callback 触发前被强制终止,本地无法生成 final 或 unknown 事件。2.0 不承诺仅靠 callback 枚举这类硬崩溃调用;它们只能通过供应商 usage 对账或额外的 start 事件审计发现。第一阶段为降低模型调用路径影响,不持久化 start 事件。

3.5 原始观测与权威投影分离

Event Inbox 保存收到的 callback_final:1 usage 观测;Usage Projector 为每个首次 accepted 的模型调用创建权威 Token 投影。v1 没有 revision 覆盖、跨来源优先级或 disputed 状态。阶段三若增加供应商对账,必须通过新 schema/migration 保留对账前后的原始事实,不能静默删除或改写历史观测。

4. 当前进程边界

EvoScientist-WebUI 与 EvoScientist 后端由同一条启动命令管理时,仍然是两个独立进程:

Browser
    -> EvoScientist-WebUI Next.js server
    -> LangGraph/EvoScientist Python process
    -> LLM Provider

WebUI 的 useStream() 只能观察图运行和 SSE。真正的模型客户端由 EvoScientist 的 get_chat_model() 创建,异步子代理和 Memory Worker 也可能在独立的 LangGraph worker 中运行。

因此:

  • WebUI 可以创建 turn_id 和回合关联上下文。
  • WebUI 不能直接运行捕获 Python 模型调用的 callback。
  • callback 必须在实际模型进程内运行。
  • callback 只负责采集和上报,统计业务仍归 EvoScientist-WebUI。

本文将模型进程内的部分称为 UsageCaptureCallback,将 WebUI 端接口称为 Usage Collector,避免把两者都称为 callback。

4.1 第一阶段支持的部署模式

2.0 第一阶段只保证以下拓扑:

EvoSci 集成启动
WebUI Next.js server 与 EvoScientist backend 同机
Collector 使用 loopback 地址

独立 WebUI 连接远端 backend 时,远端 backend 无法访问用户机器上的 127.0.0.1 Collector,因此第一阶段明确显示“Token 统计不可用”,不能显示为零。

远程模式后续通过 backend 可访问的 HTTPS Collector URL 支持,并要求 TLS、sink token、明确的网络超时和重试策略。

4.2 部署和工作区身份

每条事件必须包含:

deployment_id
workspace_id
  • deployment_id 是 EvoScientist 安装或 backend 数据目录的稳定 UUIDv4,使用小写 canonical 文本。首次生成时通过 O_CREAT|O_EXCL 原子写入 <data_dir>/deployment-id 并 fsync 文件和父目录;并发 launcher 必须读取胜出的现有值。
  • workspace_id 由 launcher 按下述 v1 算法生成一次并注入 backend/WebUI,TypeScript 不得重复实现另一套路径算法。
  • workspace_dir 仅用于本机显示和筛选,不能作为数据库身份或幂等键。

workspace identity v1 算法固定为:

path = expanduser(workspace_dir)
path = realpath(path, strict=true)
path = remove_trailing_separator_except_root(path)
path = Unicode_NFC(path)
if Windows:
    path = normcase(path)
    path = replace_backslash_with_slash(path)
else:
    path = preserve_case(path)

workspace_id = "ws1_" + lowercase_hex(
    SHA-256(utf8(deployment_id + NUL + path))
)

该算法解析符号链接;同一真实路径的 symlink 获得同一 ID。POSIX 保留大小写,Windows 按 normcase 处理盘符、UNC 和大小写。workspace 移动后路径变化,因此视为新工作区。阶段零必须使用纯函数 fixtures 覆盖 POSIX、Windows drive/UNC、大小写、symlink、根目录和 Unicode 组合字符;修改算法必须使用新的 wsN_ 前缀,不能静默改变 ws1_。

数据库中的模型调用身份是 (deployment_id, model_call_id),不能假设一个 Collector 永远只连接一个 backend。

5. 2.0 总体架构

┌──────────────────── EvoScientist-WebUI ────────────────────┐
│                                                            │
│  Chat                                                      │
│      -> human message id 作为 turn_id                      │
│                                                            │
│  POST /api/usage/events                                    │
│      -> Event Inbox                                        │
│      -> Usage Projector                                    │
│      -> Model Usage Projection                             │
│                                                            │
│  GET /api/usage/summary                                    │
│      -> WebUI Token 展示                                   │
│                                                            │
└────────────────────────────────────────────────────────────┘
                            ^
                            | idempotent UsageEvent
                            |
┌──────────────────────── EvoScientist ──────────────────────┐
│                                                            │
│  get_chat_model()                                          │
│      -> UsageCaptureCallback                               │
│      -> collect run_id + metadata + usage                  │
│      -> UsageEvent Sink                                    │
│                                                            │
│  async subagent launch                                     │
│      -> forward turn/thread correlation context            │
│                                                            │
└────────────────────────────────────────────────────────────┘
                            |
                            v
                     LLM Provider

6. EvoScientist-WebUI 职责

6.1 回合关联上下文

WebUI 直接使用 human message ID 作为 turn_id。turn_id 只用于把一次用户回合内的主代理、子代理和后台模型调用关联起来,不属于安全身份。

run metadata v1 固定为:

{
  "usage_context_version": 1,
  "turn_id": "human-message-uuid"
}

thread_id 已由 LangGraph RunnableConfig 自动进入模型 callback metadata,不要求浏览器重复生成。

@langchain/langgraph-sdk 的 run metadata 位于 stream.submit() 第二个参数的顶层 metadata,不是 config.metadata,也不属于 configurable。新消息固定使用:

stream.submit(
  { messages: [newMessage] },
  {
    metadata: {
      usage_context_version: 1,
      turn_id: newMessage.id,
    },
    config: buildRunConfig(),
    // 其他现有 stream options 保持不变
  }
);

buildRunConfig() 继续只负责模型和 LangGraph config,不保存 turn_id。阶段零必须用真实 SDK run 验证顶层 submit metadata 同时出现在模型 callback metadata 和 get_config()["metadata"]。

发送新消息时:

  • 使用新 human message ID 作为 turn_id。
  • 按上面的顶层 metadata 结构提交。

恢复 interrupt 时:

  • 从当前线程状态中找到 active interrupt 之前最后一条真实 HumanMessage,复用其 message ID。

  • summarization marker、异步完成信号等系统注入的 HumanMessage 必须过滤,不能成为 turn_id。

  • 如果无法找到对应 HumanMessage,则本次 resume 的 turn_id=null,保留 thread_id 并记录归属健康告警,不能生成猜测 ID。

  • resumeInterrupt 同样在 stream.submit(null, options) 的顶层 metadata 提交 { usage_context_version: 1, turn_id },不能只在首次消息时设置。

    2.0 不把 usage_turn_id 写入 thread metadata。当前 metadata 更新是“读取、合并、整体替换”,与标题、置顶和模型覆盖并发时可能互相覆盖;直接从消息状态恢复既减少一次远程写入,也消除了该统计字段引入的并发风险。若未来确有其他 metadata 写入需求,必须先实现统一的 mutateThreadMetadata 串行队列和带校验的重试,不能在各 hook 中自行读改写。

6.2 Usage Collector

WebUI Next.js server 提供内部事件入口:

POST /api/usage/events
POST /api/usage/sources/heartbeat
GET  /api/usage/capabilities
GET  /api/usage/status

capabilities 响应示例:

{
  "collector_instance_id": "stable-webui-collector-uuid",
  "supported_schema_versions": [1],
  "supported_topology": "same-host-integrated",
  "durable_ingest": true
}

sender 启动时先探测 capabilities:

  • 连接不到 Collector:保留 spool,按退避策略周期性重探测。集成 launcher 先启动 LangGraph、后启动 Next.js,短暂不可达属于正常启动过程。
  • 404:标记当前 WebUI 不支持 Collector,降低探测频率但不删除 spool。
  • schema 不兼容:暂停事件发送,保留 spool,周期性重探测等待兼容版本。
  • Collector 兼容:开始扫描和上报 spool。
  • UI 根据能力状态显示“可用”“后端不支持”或“Collector 不可达”,不能把不可用解释为零用量。

兼容 sender 启动后定期发送 heartbeat:

{
  "deployment_id": "backend-deployment-uuid",
  "workspace_id": "stable-workspace-id",
  "emitter_version": "2.0",
  "schema_version": 1,
  "sender_status": "healthy",
  "spool_pending": 0,
  "spool_inflight": 0,
  "spool_quarantined": 0,
  "spool_bytes": 0,
  "first_loss_at": null,
  "tracking_degraded_reason": null,
  "last_error_code": null,
  "sent_at": "2026-07-16T12:00:00Z"
}

sender_status 只允许 healthy 或 degraded。首次 spool 写入失败或因软上限拒绝事件时,sender 立即在内存中设置 first_loss_at,通过 heartbeat 交给 Collector 持久化,并尽力原子写入 <data_dir>/usage-spool/status.json。如果磁盘满、目录不可写且 Collector 同时不可达,跨重启持久化无法保证,必须输出高等级日志,不能伪造“统计完整”。UI 只有在目标 deployment 收到兼容且未过期的 heartbeat 后才显示 Token 数值;没有 heartbeat 或状态 degraded 时都不能把空数据库解释为零。

Collector 负责:

  1. 以常量时间比较服务端 sink token,并限制请求体大小。
  2. 验证 schema version、固定 source=callback_final、必填字段、字符串长度和 Token 非负安全整数范围。
  3. 按 event_id 幂等接收,并检测相同 ID 的 payload 冲突。
  4. 在同一数据库事务中写 Event Inbox 并更新 Usage Projection。
  5. 返回明确的 accepted、duplicate、conflict 或 rejected 状态。

sink token 不能暴露给浏览器。

本方案不实现用户、角色或 workspace 权限控制:

  • events、heartbeat 和 capabilities 从 WebUI cookie-auth proxy 中豁免,在各自 Route Handler 内校验 sink token。sink token 只用于确认服务端传输来源,不表达用户或 workspace 权限。
  • status、summary 和 calls 不增加独立权限层。WebUI 全局认证启用时自然受现有登录保护;全局认证关闭时,能访问该 WebUI 的客户端可以查询全部本地 Token 统计。
  • v1 是同机、单用户部署,不定义数据所有者、角色、workspace ACL 或多租户隔离。远程和多用户模式必须另立安全方案,不能宣称由本方案覆盖。

6.3 Usage Projector

Usage Projector 根据 v1 callback_final:1 事件的 usage 状态创建每个 model_call_id 的权威投影。

规则:

  1. 相同 event_id 重放是 no-op。
  2. 同一调用只有 callback_final:1;重复事件只能是 duplicate 或 conflict,不能累加。
  3. Token detail 是 input/output 的子集,不能重复计入总量。
  4. 单次调用的 total_tokens 口径为 input_tokens + output_tokens;跨调用汇总使用任意精度整数,不能使用 JavaScript Number 或 SQLite 浮点 total()。
  5. 供应商原始 total 单独保存,用于发现口径差异。
  6. 相同 event ID payload 冲突时保留原投影并记录 conflict。

6.4 查询和展示

WebUI 提供:

GET /api/usage/summary
GET /api/usage/calls

首期 summary 支持:

deployment_id
workspace_id
thread_id
turn_id
workspace_dir
provider_profile_id
model
scope
from
to

返回值必须同时包含已确认用量和未知调用数:

{
  "deployment_id": "backend-deployment-uuid",
  "workspace_id": "stable-workspace-id",
  "input_tokens": "120000",
  "output_tokens": "18000",
  "total_tokens": "138000",
  "confirmed_call_count": "23",
  "unknown_call_count": "1",
  "by_scope": [],
  "by_model": [],
  "by_provider": []
}

UsageEvent 入站 Token 字段使用 JSON number,并受单次安全整数约束。查询 API 的所有 Token 总量和调用计数使用十进制字符串,包含 summary 顶层、by_scope、by_model、by_provider 和 calls 明细,避免 JavaScript Number 精度丢失。WebUI 使用 BigInt 解析和格式化,只在显示层转为带分隔符文本,不能转回 Number 做累计。

对话输入区只保留当前线程的紧凑 Token 总量;点击后打开明细面板。明细面板提供 This chat、Workspace 和 All sources 三种隔离范围,顶部汇总 Input、Output、Total、confirmed 调用数和 unknown 调用数,下方按模型调用时间倒序列出模型、Provider、Agent/scope、线程/回合、Input、Output、Total 和 confirmed/unknown 状态。calls 每页最多请求 50 条,通过稳定游标显式加载下一页,不能一次把全部历史记录加载到浏览器。unknown 调用显示 Unknown,不能显示为 0;默认查询窗口为最近 7 天。桌面使用对齐列,移动端改为单调用分行布局,不能产生横向滚动或文本重叠。

7. EvoScientist 轻量采集探针

7.1 注入位置

项目内真实模型统一通过 get_chat_model() 创建,因此只在模型工厂注入一个 UsageCaptureCallback,不在每个 Agent、Worker 或 Middleware 中重复实现统计逻辑。

只有 EVOSCIENTIST_USAGE_TRACKING=true 且 sink、deployment、workspace 配置完整时才注入 callback。集成 WebUI launcher 和 EvoSci deploy 会自动准备这些配置;未启用统计的 CLI 和第三方直接库调用不创建 sender 线程,也不产生 spool I/O。

注入时必须保留 Provider 转换前的身份:

provider_profile_id
provider_revision
provider_adapter
model_alias
upstream_model_id

自定义 OpenAI-compatible Provider 最终会使用 openai adapter,但统计中必须保留原始 WebUI Provider Profile ID。

7.2 callback 生命周期

第一阶段不对每个流式 chunk 写数据库或发送网络请求。

on_chat_model_start
    -> 在内存中缓存 run_id 对应的 metadata

on_llm_new_token
    -> 仅当 chunk 携带 usage_metadata 时更新内存中的最后 usage
    -> 不写文件、不发送网络请求

on_llm_end
    -> 读取最终 AIMessage.usage_metadata
    -> 构造一个 final UsageEvent
    -> 交给 UsageEvent Sink
    -> 清理内存上下文

on_llm_error
    -> 如果异常或最后 chunk 含真实 usage,则上报
    -> 否则按配置上报 unknown
    -> 清理内存上下文

LangChain 自带的 UsageMetadataCallbackHandler 也采用 on_llm_end 读取最终 usage。2.0 的自定义 callback 增加的是 run 级身份、持久上报和 Provider Profile 信息,不重新实现 Token 估算器。

7.3 callback 行为约束

  • callback 错误不能向模型调用抛出。
  • callback 不能修改模型输入、输出或重试策略。
  • callback 不维护累计总数。
  • callback 每次调用最多产生一个终态事件:有真实 usage 时为 confirmed,否则为 unknown。
  • callback 必须线程安全,支持同一进程内并发模型调用。

7.4 Agent scope

scope 使用以下固定优先级,不能由组件名称做自由文本猜测:

1. 调用点显式 metadata.usage_scope
2. 受契约测试覆盖的 metadata 映射
   - lc_source=summarization -> summarizer
   - run_kind=evomemory_* -> memory
   - Scheduler / AutoSkills 的固定 run_kind -> scheduler / autoskills
   - 已知 subagent graph/run metadata -> sync_subagent / async_subagent
3. 顶层已知 Agent run -> main
4. unattributed

v1 scope 枚举固定为:

main
sync_subagent
async_subagent
tool_selector
summarizer
memory
scheduler
autoskills
diagnostic
skill_eval
unattributed

无法准确分类时使用 unattributed,但仍记录 Token。scope 分类失败不能导致实际用量丢失。

仅靠当前 metadata 不能稳定识别 Tool Selector,因为它复用了主模型。v1 固定使用 selector-only 模型副本:

  • 对 disable_thinking() 返回的 BaseChatModel 调用 model_copy(),合并原 metadata 并覆盖 usage_scope=tool_selector,只把该副本传给 LLMToolSelectorMiddleware。
  • 原主模型实例不修改;第三方中间件仍获得真实 BaseChatModel,其 with_structured_output().invoke/ainvoke 会把模型静态 metadata 传给 callback。
  • 异步子代理启动附加 usage_scope=async_subagent。
  • Memory Worker 启动附加 usage_scope=memory;Scheduler 和 AutoSkills 复用已有固定 run_kind 映射,避免修改其执行代码。
  • summarizer 复用框架已有的 lc_source=summarization,并用契约测试锁定;若框架升级后该字段消失,再在 summarizer 模型调用点显式补标签。

所有正式支持的 Chat Model 必须通过 selector metadata-copy contract test。模型工厂在注入 Usage callback 前预检 model_copy() 是否返回真实 BaseChatModel:

  • 预检通过:正常注入 callback,并为 selector 创建带 usage_scope=tool_selector 的副本。
  • 预检失败或返回 RunnableBinding:不因统计抛错,不改变原有 selector 模型路径;跳过该模型的 Usage callback 注入,并通过 heartbeat 报告 tracking_degraded_reason=selector_model_copy_unsupported。
  • 降级模型没有事件,不能伪造为 main、tool_selector 或 unknown;UI 必须显示统计可能不完整。

这些修改不能改变 prompt、模型参数、Middleware 顺序、retry、fallback 或工具选择结果。source_agent 用于说明调用者身份,不能替代 usage_scope。

8. UsageEvent 协议

以下 JSON 是可读示例,不是跨语言协议的唯一来源:

{
  "schema_version": 1,
  "event_id": "<deployment-id>:<run-id>:callback_final:1",
  "event_type": "usage_observed",
  "source": "callback_final",
  "authority_class": "observed_final",
  "revision": 1,

  "deployment_id": "backend-deployment-uuid",
  "workspace_id": "stable-workspace-id",
  "model_call_id": "langchain-run-id",
  "parent_run_id": "optional-parent-run-id",
  "provider_request_id": "optional-provider-request-id",

  "thread_id": "langgraph-thread-id",
  "source_session_id": "optional-parent-session-id",
  "turn_id": "webui-turn-id",
  "workspace_dir": "/workspace/path",
  "scope": "main",
  "source_agent": "EvoScientist",

  "provider_profile_id": "webui-provider-id",
  "provider_revision": "provider-config-revision",
  "provider_adapter": "openai",
  "model_alias": "chat-main",
  "upstream_model_id": "gpt-upstream",

  "usage_status": "confirmed",
  "input_tokens": 1000,
  "output_tokens": 200,
  "provider_total_tokens": 1200,
  "input_token_details": {},
  "output_token_details": {},

  "started_at": "2026-07-16T12:00:00Z",
  "observed_at": "2026-07-16T12:00:03Z",
  "completed_at": "2026-07-16T12:00:03Z"
}

event_id 精确定义为 deployment_id + ":" + model_call_id + ":" + source + ":" + decimal(revision),不包含跨语言 canonical JSON hash,也不通过分隔符反向解析字段。Collector 的应用层 validator 必须按事件字段重新计算并校验 event ID;不匹配时 rejected,不能直接信任 sender 提供的 ID。Collector 在接收端对规范化后的 payload 计算 SHA-256:

  • event ID 和 payload hash 都相同:返回 duplicate。
  • event ID 相同但 payload hash 不同:写入 conflict 记录并返回 conflict。
  • conflict 不能更新权威投影。v1 没有更高 revision,只能由诊断处理 quarantine;不能自动选择任一 payload。

事件中禁止包含:

  • API Key
  • prompt 全文
  • 模型输出全文
  • 完整请求头
  • Provider Profile 中的 secret
  • 未经白名单过滤的原始响应对象

8.1 必须落盘的契约工件

阶段零必须在 EvoScientist-WebUI 中提交:

docs/schemas/usage-event-v1.schema.json
docs/schemas/usage-api-v1.md
docs/schemas/usage-spool-v1.md
docs/schemas/fixtures/accepted/*.json
docs/schemas/fixtures/rejected/*.json
docs/schemas/fixtures/projection/*.json
docs/schemas/fixtures/spool/*.json
docs/schemas/fixtures/query/*.json
docs/schemas/fixtures/identity/*.json
docs/schemas/fixtures/metadata/*.json
  • JSON Schema 是 UsageEvent 字段、类型、枚举、长度和 nullability 的唯一事实源,使用 additionalProperties: false。
  • API 文档固定 endpoint、sink transport authentication、请求/响应 envelope、HTTP 状态、sender 动作、查询限制和版本协商;不定义用户或 workspace 权限。
  • Spool 文档固定 tmp -> pending -> inflight -> delete/quarantine 状态机、锁顺序、每个崩溃点的恢复动作和本地文件系统前提。
  • Python Pydantic 模型和 TypeScript validator 必须共同运行同一组 fixtures;任意一端结果不同都阻止合并。
  • Projection fixtures 以事件序列加期望投影表达重复、unknown、非法 revision 拒绝和 payload conflict,不允许 Python 与 TypeScript 各自解释规则。
  • Spool fixtures 和 fault-injection 测试覆盖文件/目录 fsync 前后、claim 前后、HTTP ACK 前后和进程退出后的恢复。
  • Query fixtures 覆盖 BigInt 聚合和十进制字符串;identity fixtures 覆盖 deployment/workspace ID;metadata fixtures 覆盖新消息、resume、异步子代理和 Memory 透传。
  • schema_version 是整数版本。sender 从 capabilities 选择双方支持的最高版本;没有交集时停止发送并保留 spool。字段语义、必填性或枚举变化必须发布新版本,不能静默改变 v1。

8.2 v1 字段和规范化约束

v1 冻结以下约束。所有 schema properties 都必须出现在 payload 中,可空字段使用显式 null,不能通过省略表达另一种语义:

类别 约束
非空控制字段 schema_version、event_id、event_type、source、authority_class、revision
非空身份字段 deployment_id、workspace_id、model_call_id、scope、provider_profile_id、provider_adapter、model_alias、upstream_model_id
可空关联字段 parent_run_id、provider_request_id、thread_id、source_session_id、turn_id、workspace_dir、source_agent、provider_revision
usage 字段 usage_status 非空;input_tokens、output_tokens、provider_total_tokens 可空;两个 details 字段为对象且无明细时使用 {}
时间字段 observed_at、completed_at 非空;started_at 可空
event_type v1 只允许 usage_observed
source v1 固定为 callback_final
authority_class v1 固定为 observed_final
usage_status confirmed、unknown
Token confirmed 要求 input/output 为 0..9007199254740991 的整数,且两者之和仍不超过该上限;provider total 可空;unknown 要求三个 Token 数字字段均为 null
revision v1 使用 JSON Schema const: 1,普通 sender 不维护 revision 状态
字符串 原子 ID 和枚举最多 256 字符,event_id 最多 1024 字符,模型字段最多 512 字符,workspace_dir 最多 4096 字符;禁止控制字符
时间 RFC 3339 UTC;completed_at 非空;started 不得晚于 observed/completed
请求体 单事件 JSON,UTF-8,最大 256 KiB;v1 不接受批量数组

Schema 使用 const 固定 source=callback_final、authority_class=observed_final 和 revision=1。应用层 validator 额外验证 input_tokens + output_tokens <= 9007199254740991。usage_status 可以是 confirmed 或 unknown。Gateway、partial、provider_reconciled、provider_only 或其他 source 一律 rejected。

Collector 只对通过 schema 验证的逻辑事件计算 hash。规范化算法固定为 RFC 8785 JSON Canonicalization Scheme,再对 UTF-8 bytes 计算 SHA-256。验证前的原始请求体不写 Inbox。

8.3 Collector 响应和 sender 动作

HTTP 响应状态 sender 动作
200 accepted / duplicate 删除 inflight 文件
400 / 422 / 413 rejected 移入 quarantine,记录原因
409 conflict 移入 quarantine,不更新投影
401 / 403 unauthorized 保留事件、暂停发送并报告配置错误
404 unsupported 保留事件,按不支持 Collector 的低频策略重探测
426 schema_incompatible 保留事件,重新协商 capabilities
429 / 5xx retryable 回 pending,按退避策略重试
网络失败/超时 无 回 pending,按退避策略重试

POST /api/usage/events 的响应 envelope 至少包含 status、event_id 和稳定的 reason_code;服务端不得在事务提交前返回 200。401/403 和 426 不能把事件移入 quarantine,因为更正 token 或升级版本后仍可成功上报。

8.4 v1 Projection 合并契约

v1 没有 authority/source priority 排序,合并顺序固定为:

  1. 相同 event ID 和 payload hash 是 duplicate。
  2. 相同 event ID、不同 payload hash 进入 event conflict,不能更新投影。
  3. 首次 accepted 事件创建 model_usage;此后同一调用只能 duplicate 或 conflict,不存在 revision 覆盖。
  4. unknown 不进入 confirmed Token 总量,但计入 unknown 调用数。

v1 不存在 disputed。未来引入第二个投影来源时必须升级 schema 和数据库,并在事件入库时保存不可变 projection_policy_version;不能依赖当前 Provider Profile 或事件到达顺序。

9. 异步子代理和后台任务

9.1 同进程调用

主代理、同步子代理、Tool Selector 和摘要器通常继承同一个 RunnableConfig。当前 LangGraph 会自动把 thread_id、model、node 和 run metadata 传到模型 callback,因此不在每个组件增加统计中间件;仅 Tool Selector 需要补一个调用级 usage_scope,summarizer 使用已有且经过测试的 lc_source 映射。

9.2 异步子代理

异步子代理运行在独立 LangGraph run 和可能不同的进程中,Python callback 对象和 parent_run_id 不会跨 HTTP 自动传播。

启动异步 run 时,复用现有 model passthrough 接入点,额外传递:

turn_id
source_session_id
usage_scope=async_subagent
source_agent
workspace_dir

每个异步子代理模型调用仍使用自己的 LangChain run_id,source_session_id 仅用于归属主会话,不能作为幂等主键。

9.3 Memory、Scheduler 和 AutoSkills

Memory Worker 已经携带 run_kind、source_session_id、source_agent 和 workspace metadata,但当前 MemorySourceContext 没有回合身份。2.0 增加可空的 turn_id,只做归属透传:

MemorySourceContext.turn_id: str | None
    <- build_memory_source_context() 从 get_config().metadata.turn_id 读取
    -> worker metadata.turn_id
    -> worker configurable.evomemory_source_turn_id
    -> UsageCaptureCallback

由用户回合触发的 Memory Worker 必须继承该回合 ID;脱离用户回合的后台整理保持 turn_id=null。这项修改不改变 Memory prompt、存储、调度或执行逻辑。

独立 Scheduler 或 AutoSkills 没有用户回合时,可以没有 turn_id,但必须保留 workspace、scope、Provider 和模型信息。

10. UsageEvent 传输

10.1 配置

集成启动模式和 EvoSci deploy 由 launcher 注入:

EVOSCIENTIST_DATA_DIR=<absolute-shared-data-dir>
EVOSCIENTIST_USAGE_TRACKING=true
EVOSCIENTIST_USAGE_SINK_URL=http://127.0.0.1:<webui-port>/api/usage/events
EVOSCIENTIST_USAGE_SINK_TOKEN=<server-only-secret>
EVOSCIENTIST_DEPLOYMENT_ID=<stable-backend-uuid>
EVOSCIENTIST_WORKSPACE_ID=<stable-workspace-id>
EVOSCIENTIST_USAGE_SPOOL_DIR=<data_dir>/usage-spool

data_dir 的解析规则在 Python 和 TypeScript 中必须一致:优先使用绝对路径 EVOSCIENTIST_DATA_DIR;未设置时使用当前用户 home 下的 .evoscientist。launcher 将解析后的绝对路径注入两个进程,子进程不能各自重新解释 ~。deployment ID、sink token、spool、Collector ID 和 usage database 都从该目录派生;若单独设置 EVOSCIENTIST_USAGE_SPOOL_DIR,该值也必须是绝对路径。

usage sink token 是独立的随机服务端密钥,首次生成后保存在 <data_dir>/usage-sink-token 并设置仅当前用户可读。它不能复用 Provider Admin Token 或 WebUI 登录密钥,也不携带用户、角色或 workspace 权限。集成 launcher 在启动 backend 和 WebUI 前生成并分别注入两端环境。独立启动时,EvoSci deploy 生成 backend 环境,WebUI 的采集接口在没有显式环境变量时按请求读取同一文件,因此两端启动顺序不限;WebUI 尚未启动期间的事件和心跳先进入 durable spool,连接恢复后补发。

无法创建 sink 配置时,EvoScientist 维持原行为并显示警告,不能因为 WebUI Token 功能缺失而阻止独立 EvoSci deploy、CLI 或第三方 LangGraph 客户端运行。

10.2 调用路径约束和可靠性

第一阶段默认使用 durable spool,不提供仅内存的 standard 模式。callback 不等待 HTTP,只在终态 callback 中同步完成一次小型原子文件写入:

on_llm_end / on_llm_error
    -> event_file_key = SHA-256(event_id)
    -> write <event_file_key>.<pid>.<random>.tmp
    -> fsync file
    -> 用 O_CREAT|O_EXCL 获取短生命周期 <event_file_key>.lock
    -> atomic replace to spool/pending/<event_file_key>.json
    -> fsync parent directory where supported
    -> release lock
    -> return without waiting for Collector

background sender
    -> claim pending event
    -> POST Collector
    -> accepted/duplicate: delete
    -> conflict/rejected: move to quarantine
    -> network/5xx: retry with backoff

spool 默认位于:

<data_dir>/usage-spool/
    pending/
    inflight/
    quarantine/

usage-spool-v1.md 是 spool 状态机的唯一事实源;上面的流程图只用于说明。v1 只保证本地文件系统上的原子 create/rename/fsync 语义,不支持把 spool 放在 NFS、SMB、对象存储挂载或其他无法保证相同语义的网络文件系统。workspace 和大文件存储可以远程,但 usage spool 必须本地;无法满足时关闭 Token 统计并在 UI 显示不可用,不能影响模型调用。

spool 文件名不能直接使用含冒号的 event_id,否则 Windows 无法创建文件。文件名固定使用小写十六进制 SHA-256(event_id);事件正文保留原始 event_id,Collector 的幂等判断不依赖文件名。唯一临时名避免多 worker 共用 <key>.json.tmp;短锁只保护同一个 event key,不形成全局锁。若 pending/inflight 已有同 event ID 和 payload hash,则丢弃新临时文件;同 event ID payload 不同时移入本地 quarantine。进程启动时清理超过 inflight lease 的 stale lock 和 orphan temp。

多个 LangGraph worker 可能共享 spool。sender 通过原子 rename 将 pending 文件声明为 inflight;进程启动时回收超过租约时间的 stale inflight 文件,保证崩溃后能够继续发送。

durable spool 只是传输 outbox,不是 Token 权威数据库。权威数据仍在 EvoScientist-WebUI。它只保存白名单 UsageEvent,不保存 prompt、输出或 Provider secret。

spool 写入或事件上报失败必须 fail-open,不能把成功模型响应转换成失败,也不能因此触发模型 fallback。spool 写入失败需要高等级日志和健康状态,因为该事件此后无法自动恢复。

同步落盘会增加终态 callback 延迟,因此阶段零必须在 macOS、Linux、Windows 的发布参考本地 SSD 上测试 1 KiB 事件和 1/4/16 worker 并发。相对关闭统计的额外延迟门槛为 P95 不超过 20 ms、P99 不超过 100 ms;任何目标平台不通过时,阶段一不能发布,必须重新设计 outbox 写入方式,不能用“文件很小”代替性能证据。

10.3 复用已有后端

集成 launcher 可以在启动 LangGraph 子进程前注入 sink 环境变量。若 WebUI 复用一个已经运行的 EvoScientist backend,该 backend 不会自动获得新环境变量,需要:

  • 使用已配置 sink 的 backend;或
  • 重启 backend;或
  • 后续增加受认证的动态 sink 注册能力。

第一阶段不增加动态 sink 注册,避免引入任意 URL 注入和 SSRF 风险。复用 backend 若未配置兼容 Collector,UI 明确显示统计不可用。

10.4 运行参数默认值

第一版使用以下默认值,并允许通过同名配置覆盖。配置名、单位和上下界必须写入 usage-api-v1.md,Python 与 TypeScript 不得各自保留另一套常量。

配置键 默认值 行为
EVOSCIENTIST_USAGE_HEARTBEAT_INTERVAL_SECONDS 15 sender 正常运行时上报
EVOSCIENTIST_USAGE_HEARTBEAT_TTL_SECONDS 45 超时后 UI 标记 backend 离线,不显示零用量
EVOSCIENTIST_USAGE_HTTP_CONNECT_TIMEOUT_SECONDS / EVOSCIENTIST_USAGE_HTTP_TIMEOUT_SECONDS 1 / 3 超时后事件回 pending
EVOSCIENTIST_USAGE_RETRY_INITIAL_SECONDS / EVOSCIENTIST_USAGE_RETRY_MAX_SECONDS 1 / 60 2 倍增长、20% jitter,适用于网络、429 和 5xx
EVOSCIENTIST_USAGE_UNSUPPORTED_REPROBE_SECONDS 300 capabilities 404 后保留 spool
EVOSCIENTIST_USAGE_SCHEMA_REPROBE_SECONDS 60 schema 不兼容时保留 spool
EVOSCIENTIST_USAGE_INFLIGHT_LEASE_SECONDS 120 超时文件由 sender 原子回收到 pending
EVOSCIENTIST_USAGE_MAX_EVENT_BYTES 262144 超限进入 quarantine
EVOSCIENTIST_USAGE_SPOOL_MAX_FILES / EVOSCIENTIST_USAGE_SPOOL_MAX_BYTES 100000 / 1073741824 先到者为软上限;不自动删除未确认事件
EVOSCIENTIST_USAGE_QUARANTINE_RETENTION_DAYS 90 到期清理前输出计数和原因;pending/inflight 不按时间淘汰
EVOSCIENTIST_USAGE_INBOX_RETENTION_DAYS 90 Projection 长期保留
EVOSCIENTIST_USAGE_QUERY_DEFAULT_DAYS / EVOSCIENTIST_USAGE_QUERY_MAX_DAYS 7 / 90 更大范围返回 422
EVOSCIENTIST_USAGE_CALLS_PAGE_SIZE / EVOSCIENTIST_USAGE_CALLS_MAX_PAGE_SIZE 50 / 500 使用稳定游标,不允许无界查询

spool 达到软上限意味着统计链路已不完整,/api/usage/status 必须显示 degraded 和首次丢失时间。不能为了继续写入而静默删除最旧 pending 事件。

11. Provider usage 策略

11.1 第一阶段不改变请求参数

原生 OpenAI、Anthropic 和部分 Provider 已经返回标准 usage_metadata。自定义 OpenAI-compatible 网关对 stream_options.include_usage 的支持不一致。

为避免 Token 统计改变模型行为,第一阶段:

  • 不对所有 Provider 强制设置 stream_usage=True。
  • 不因缺少 usage 自动重试模型。
  • 不从 prompt 或文本长度估算 Token。
  • 缺少真实 usage 时记录 unknown。

11.2 后续 Provider 能力配置

验证具体网关后可增加:

stream_usage_mode = auto | required | disabled

该配置属于 Provider Profile 能力,不应根据 provider_adapter=openai 一刀切。

11.3 阶段零 Provider 兼容性矩阵

实现 callback 前,必须对项目锁定的 SDK/LangChain 版本生成真实或可回放的响应 fixtures。矩阵记录“字段实际出现在哪里”,不能只根据 Provider 名称假设:

Provider 类型 非流式 usage 流式 usage request ID 阶段一结论
原生 OpenAI AIMessage.usage_metadata 与原始 response metadata 验证默认 stream_usage 和最后 chunk 验证 response metadata/header 映射 confirmed 或明确 unknown
原生 Anthropic AIMessage.usage_metadata 验证最后 chunk/最终 message 验证 response metadata confirmed 或明确 unknown
自定义 OpenAI-compatible,支持 include_usage 用兼容网关 fixture 验证 显式验证 stream_options.include_usage 验证网关字段 Provider Profile 标记能力后启用
自定义 OpenAI-compatible,不支持 include_usage 验证非流式结果 不改变请求,预期可为 unknown 有则保存 unknown,不重试、不估算
fallback/retry 每个 LangChain run 独立 fixture 同左 保存每次可见 request ID 验证调用数而非只验 Token 总和

每一行至少覆盖成功、流式、缺 usage、模型错误四类 fixture,并记录 SDK 版本。Provider 兼容性失败不阻止统计框架上线,但必须把相应调用稳定标记为 unknown,不能误报 confirmed。

11.4 retry 和 fallback

  • 每个 LangChain fallback 调用有独立 model_call_id,分别计量。
  • LangChain 外层 retry 如果产生新的 run_id,分别计量。
  • Provider SDK 内部 HTTP retry 可能仍共享一个 run_id,本地 callback 无法判断前一次请求是否产生了额外用量。
  • SDK 内部 retry 的额外用量只能通过供应商 usage 对账发现。

12. WebUI 数据模型

2.0 将 Event Inbox 和权威投影保存在 EvoScientist-WebUI 服务端 SQLite。运行时驱动确定为 better-sqlite3,作为 npm runtime dependency 安装,不把构建机生成的 native binary 固化进发布包。

Next.js 配置将 better-sqlite3 列入 serverExternalPackages,使 npx 安装时在目标机器为当前 Node ABI 和平台安装依赖。standalone 组装脚本必须验证运行时能从 package 根目录解析该依赖。

首期发布门槛包括 macOS、Linux、Windows 的 npm pack -> npx/install -> start:dist 测试。任何目标平台无法安装或加载 SQLite native module 时,不发布该版本。

数据库位于:

<data_dir>/webui/usage.db

不能写入 npm package 的 dist/ 目录,也不复用 EvoScientist sessions.db。

数据库初始化使用:

PRAGMA journal_mode=WAL;
PRAGMA synchronous=FULL;
PRAGMA busy_timeout=5000;
PRAGMA foreign_keys=ON;

Collector 只有在事务提交完成后才返回 accepted/duplicate,sender 收到确认后才删除 spool。synchronous=FULL 用于避免 Collector 已确认、spool 已删除后因主机异常丢失最近提交。

  • 使用 PRAGMA user_version 管理 migration。
  • collector_instance_id 首次启动时生成,并持久化在 WebUI 数据目录;数据库清空不会静默复用其他 Collector 的身份。
  • 通过 globalThis 维护进程级单例连接,避免 Next.js 开发热更新重复打开连接。
  • 所有写入使用短事务。
  • API 查询必须分页并设置时间范围上限。
  • 定义 Inbox 保留期和数据库备份方式;默认保留原始事件 90 天,权威投影长期保留。

better-sqlite3 连接启用 safe integers。汇总不能使用 SQLite total() 或让结果进入 JavaScript Number;WebUI 注册确定性的 decimal_sum aggregate,以 BigInt 累加 SQLite INTEGER 并返回十进制字符串。COUNT(*) 也以 BigInt 读取并序列化为十进制字符串。数据库、API 和 UI 的共享 fixtures 必须覆盖汇总值超过 9007199254740991 的情况。

12.1 usage_sources

CREATE TABLE usage_sources (
    deployment_id TEXT NOT NULL,
    workspace_id TEXT NOT NULL,
    emitter_version TEXT NOT NULL,
    schema_version INTEGER NOT NULL,
    sender_status TEXT NOT NULL CHECK (sender_status IN ('healthy', 'degraded')),
    spool_pending INTEGER NOT NULL DEFAULT 0,
    spool_inflight INTEGER NOT NULL DEFAULT 0,
    spool_quarantined INTEGER NOT NULL DEFAULT 0,
    spool_bytes INTEGER NOT NULL DEFAULT 0,
    first_loss_at TEXT,
    tracking_degraded_reason TEXT,
    last_error_code TEXT,
    last_seen_at TEXT NOT NULL,

    PRIMARY KEY (deployment_id, workspace_id)
);

usage_sources 由 heartbeat 更新,供 /api/usage/status 判断目标 backend 是否兼容和在线。first_loss_at 使用 earliest-non-null 合并:已存非空值不能被后续 null 或更晚时间覆盖;一旦存在,Collector 的完整性状态保持 degraded。普通 heartbeat 不能重置该状态,v1 只允许通过清空对应统计数据恢复“完整”语义。

12.2 usage_event_inbox

CREATE TABLE usage_event_inbox (
    event_id TEXT PRIMARY KEY,
    deployment_id TEXT NOT NULL,
    model_call_id TEXT NOT NULL,
    source TEXT NOT NULL CHECK (source = 'callback_final'),
    authority_class TEXT NOT NULL CHECK (authority_class = 'observed_final'),
    revision INTEGER NOT NULL CHECK (revision = 1),
    payload_hash TEXT NOT NULL,
    payload_json TEXT NOT NULL,
    received_at TEXT NOT NULL
);

Inbox 用于网络重试幂等和最小审计,不承担聚合查询。

Inbox 按保留期清理后仍必须保留最小幂等墓碑;否则 90 天后的 sender 重放会因 model_usage 主键冲突而无法判定 duplicate。数据库 migration v2 增加:

CREATE TABLE usage_event_idempotency (
    event_id TEXT PRIMARY KEY,
    payload_hash TEXT NOT NULL,
    retained_at TEXT NOT NULL
);

墓碑不包含 prompt、输出或完整 payload,长期保留并继续支持 duplicate/conflict 判定。

12.3 usage_event_conflicts

CREATE TABLE usage_event_conflicts (
    conflict_id TEXT PRIMARY KEY,
    event_id TEXT NOT NULL,
    stored_payload_hash TEXT NOT NULL,
    received_payload_hash TEXT NOT NULL,
    received_payload_json TEXT NOT NULL,
    received_at TEXT NOT NULL,

    UNIQUE (event_id, received_payload_hash)
);

相同 event_id 但 payload 不同时写入 conflict,不能更新权威投影。conflict_id 固定为 SHA-256(event_id + NUL + received_payload_hash);即使 409 响应在网络中丢失,同一冲突重放也不会产生重复记录。

12.4 model_usage

CREATE TABLE model_usage (
    deployment_id TEXT NOT NULL,
    workspace_id TEXT NOT NULL,
    model_call_id TEXT NOT NULL,
    source TEXT NOT NULL CHECK (source = 'callback_final'),
    authority_class TEXT NOT NULL CHECK (authority_class = 'observed_final'),
    revision INTEGER NOT NULL CHECK (revision = 1),

    parent_run_id TEXT,
    provider_request_id TEXT,
    thread_id TEXT,
    source_session_id TEXT,
    turn_id TEXT,
    workspace_dir TEXT,
    scope TEXT NOT NULL DEFAULT 'unattributed',
    source_agent TEXT,

    provider_profile_id TEXT NOT NULL,
    provider_revision TEXT,
    provider_adapter TEXT NOT NULL,
    model_alias TEXT NOT NULL,
    upstream_model_id TEXT NOT NULL,

    usage_status TEXT NOT NULL CHECK (usage_status IN ('confirmed', 'unknown')),
    input_tokens INTEGER
        CHECK (input_tokens IS NULL OR input_tokens BETWEEN 0 AND 9007199254740991),
    output_tokens INTEGER
        CHECK (output_tokens IS NULL OR output_tokens BETWEEN 0 AND 9007199254740991),
    provider_total_tokens INTEGER
        CHECK (provider_total_tokens IS NULL OR provider_total_tokens BETWEEN 0 AND 9007199254740991),
    input_details_json TEXT NOT NULL,
    output_details_json TEXT NOT NULL,

    started_at TEXT,
    observed_at TEXT NOT NULL,
    completed_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,

    PRIMARY KEY (deployment_id, model_call_id),
    CHECK (
        (usage_status = 'confirmed' AND input_tokens IS NOT NULL AND output_tokens IS NOT NULL)
        OR
        (usage_status = 'unknown' AND input_tokens IS NULL AND output_tokens IS NULL
            AND provider_total_tokens IS NULL)
    ),
    CHECK (
        usage_status = 'unknown'
        OR input_tokens + output_tokens <= 9007199254740991
    )
);

CREATE INDEX model_usage_thread_idx
    ON model_usage(deployment_id, thread_id, completed_at);

CREATE INDEX model_usage_source_session_idx
    ON model_usage(deployment_id, source_session_id, completed_at);

CREATE INDEX model_usage_turn_idx
    ON model_usage(deployment_id, turn_id, completed_at);

CREATE INDEX model_usage_provider_idx
    ON model_usage(deployment_id, provider_profile_id, upstream_model_id, completed_at);

处理事件时使用一个短事务:

BEGIN
    查询 event_id 是否存在
    如果不存在:插入 usage_event_inbox
    如果存在且 payload_hash 相同:返回 duplicate
    如果存在且 payload_hash 不同:插入 usage_event_conflicts,返回 conflict
    对首次 accepted 事件:INSERT model_usage
COMMIT

/api/usage/calls 的稳定游标由 completed_at + deployment_id + model_call_id 组成,同一时间戳或跨 deployment 时不能漏项或重复。

13. 供应商 usage 对账

供应商对账不属于 v1,也不能调用 /api/usage/events。阶段三若 Provider 或统一网关提供逐请求用量接口,必须先发布新 schema version 和数据库 migration,再由 EvoScientist-WebUI 内部任务执行:

  1. 按 Provider Profile 和时间窗口获取供应商 usage 记录。
  2. 使用 provider_request_id 匹配本地调用。
  3. 生成内部 provider_reconciled 事实,不经过 callback sink token 或外部 Collector route。
  4. 更新同一个 model_call_id 的权威投影。
  5. 记录对账前后的 Token 差异。

新版本必须保存不可变 projection_policy_version 和对账任务身份。普通 sender 永远不能声明 provider_reconciled、provider_only 或其他高权威来源;Provider Profile 后续修改不能改变已经采用的历史策略。

若供应商有记录而本地没有对应调用,使用供应商 request ID 创建合成 model call,并标记 source=provider_only。其 model_call_id 固定为 provider-only: 加 SHA-256(deployment_id + NUL + provider_profile_id + NUL + provider_request_id);thread_id、turn_id 和 source_session_id 为 null,scope=unattributed。供应商记录没有逐请求 ID 时不能创建合成调用。

如果供应商只提供时间窗口汇总,系统只能保存 Provider 级差异记录,不能伪造单次调用明细。

截至 2026-07-16,当前接入的 OpenAI 和 Anthropic 官方组织 Usage API 均只提供时间桶聚合,不返回可用于逐调用匹配的 request ID。因此当前实现通过数据库 migration 3 保存内部 aggregate-report-v1 观测,并按 deployment、Provider、模型和时间窗口生成任意精度 Token/请求数差异报告;该报告固定 projection_updated=false,不修改 model_usage,也不创建 provider_only 调用。能力判断和官方接口依据见 docs/schemas/provider-reconciliation-capabilities.md。未来出现逐请求接口时,仍必须按上面的新 schema 和不可变投影策略发布,不能复用聚合报告静默改写历史。

14. 故障处理

场景 行为
callback 重复上报 Collector 按 event_id 幂等忽略
final 重复到达 相同 payload 为 duplicate;不同 payload 为 conflict,均不累加
sender 提交 partial/Gateway/对账 source v1 schema rejected 并移入 quarantine
WebUI Collector 暂时不可用 事件保留在 spool,后台退避重试
callback 自身异常 记录日志,不影响模型响应
Provider 不返回 usage 标记 unknown,不估算、不计入已确认总量
Worker 在终态 callback 前硬退出 本地无事件;等待供应商 usage 对账,不伪造 unknown
异步子代理独立进程 透传回合关联上下文,使用自己的 run_id
SDK 内部 retry 无法区分 等待供应商对账
WebUI 数据库写入失败 Collector 返回失败,sender 重试
sink token 错误或 schema 不兼容 保留 spool 并暂停发送,修正配置/升级后恢复
spool 达软上限或写入失败 模型调用继续;内存标记 degraded,heartbeat/状态文件尽力持久化 first_loss_at
同 event ID 出现不同 payload 写 conflict,保持原投影
供应商对账修正 更新 Token 投影并保留差异记录

15. 最小修改范围

15.1 EvoScientist-WebUI

建议新增:

src/app/api/usage/events/route.ts
src/app/api/usage/capabilities/route.ts
src/app/api/usage/sources/heartbeat/route.ts
src/app/api/usage/status/route.ts
src/app/api/usage/summary/route.ts
src/app/api/usage/calls/route.ts
src/lib/server/usageStore.ts
    启用 safe integers,注册 decimal_sum BigInt aggregate
src/lib/server/usageProjector.ts
src/lib/server/usageMigrations.ts
src/lib/usageTypes.ts
src/app/hooks/useUsage.ts
docs/schemas/usage-event-v1.schema.json
docs/schemas/usage-api-v1.md
docs/schemas/usage-spool-v1.md
docs/schemas/fixtures/

建议修改:

src/app/hooks/useChat.ts
    新消息和 resume 都通过 submit options 顶层 metadata 提交 turn_id,不写 thread metadata

src/proxy.ts
    cookie-auth proxy 豁免 events/heartbeat/capabilities;各 Route Handler 自行校验 sink token

ChatInterface 或独立 Usage 面板
    查询和展示 confirmed/unknown 用量

package.json / Next config / standalone build
    externalize better-sqlite3,包含 migration 并验证目标平台运行时解析

15.2 EvoScientist

只修改以下接入点:

新增 usage/callback.py、usage/sink.py 和 usage/spool.py
    采集、原子 spool 并上报标准 UsageEvent

llm/models.py
    model_copy 预检通过后注入 callback 和静态 Provider metadata;失败只降级统计

llm/patches.py
    异步子代理透传回合关联上下文和 source_session_id

middleware/tool_selector.py
    用 model_copy 创建 selector-only metadata 副本,不修改主模型

memory/source_context.py、memory/launch.py
    从 get_config().metadata 读取可空 turn_id 并透传给 Memory Worker

deploy/webui.py
    原子生成 deployment_id,按 ws1 算法生成 workspace_id,并注入 sink URL/token

明确不修改:

  • 主 Agent 构建逻辑
  • Agent Middleware 顺序
  • Tool Selector 的 prompt、模型参数和选择行为
  • Memory Worker 的 prompt、存储、调度和执行逻辑
  • Scheduler 核心逻辑
  • stream/events.py
  • stream/state.py
  • checkpoint schema
  • Provider 请求和 retry 行为

16. 分阶段实施

阶段零:实施契约冻结

  1. 提交 JSON Schema、API 契约、spool 状态机和 accepted/rejected/projection/spool 共享 fixtures。
  2. Python Pydantic 与 TypeScript validator 对共享 fixtures 取得完全一致的结果。
  3. 用真实 callback metadata fixture 验证 main、subagent、Tool Selector selector-only model copy、summarizer、Memory、Scheduler 和 AutoSkills 的 scope 映射。
  4. 完成 Provider usage 兼容性矩阵,记录锁定 SDK 版本、usage 与 request ID 的真实位置。
  5. 完成 macOS、Linux、Windows 的 better-sqlite3 安装、migration 和 standalone 启动 spike。
  6. 验证 data_dir、deployment ID、sink token、spool 和 database 在 Python/TypeScript 两端解析到相同绝对路径。
  7. 在三个平台完成 spool 每个崩溃点的 fault injection、多 worker claim/recovery 和本地文件系统语义测试。
  8. 完成同步 spool 的 1/4/16 worker 延迟基准并满足 P95/P99 门槛。
  9. 验证 ingest route 绕过 cookie proxy 后仍强制 sink token,查询 route 不增加 workspace ACL。
  10. 用真实 useStream.submit 验证顶层 metadata 在 callback 和 get_config() 中一致,新消息/resume 都覆盖。
  11. 用跨平台 workspace fixtures 验证 ws1_ 算法和 deployment ID 并发创建。
  12. 验证 revision=1 const、单次 Token 和安全、BigInt 聚合及十进制字符串 API。

阶段零是编码门禁。schema、API 状态矩阵、Projection 合并规则、spool 状态机、scope 标签和 Memory turn 归属未通过共享测试前,不并行实现 sender 与 Collector;阶段零通过后,各模块可以按契约独立开发。

阶段一:Token 事实链路

  1. WebUI 接入 better-sqlite3、migration 和跨平台 standalone 打包测试。
  2. WebUI 实现 capabilities、heartbeat/status、Collector、Inbox、conflict、Projection 和 summary API。
  3. EvoScientist 实现轻量 callback、durable spool、capability 探测、heartbeat 和 HTTP sender。
  4. 模型工厂完成 selector model-copy 预检后注入 callback,保留 Provider Profile 身份;失败时 Agent 正常运行且状态 degraded。
  5. WebUI 使用 human message ID 创建 turn_id,interrupt resume 从消息状态恢复。
  6. 增加 Tool Selector 显式 scope、异步子代理上下文和 Memory 可空 turn_id 透传。
  7. UI 展示线程和回合级 confirmed Token、unknown 数量及 Collector 能力状态。
  8. 验证 backend 和 WebUI 进程重启后的 spool 重放与幂等。

阶段一不强制 stream usage,也不实现供应商对账。

阶段二:异步归属和运行健康

  1. 增加 Collector 健康状态、spool 积压、首次丢失时间和 quarantine 监控。
  2. 扩大多 worker 压力测试,验证软上限和长期 stale inflight 回收。
  3. 增加 Inbox/quarantine 保留任务和备份恢复演练。

阶段三:供应商 usage 能力门禁和差异报告

  1. 记录当前 Provider Usage API 的字段和粒度能力;没有 request ID 时禁止逐调用 reconciliation。
  2. 通过数据库 migration 3 保存内部聚合 usage 观测和固定 aggregate-report-v1 policy。
  3. 生成精确的 Provider 时间窗差异报告,保持 projection_updated=false。
  4. 外部 /api/usage/events 继续拒绝 reconciliation source;未来逐请求接口必须发布新 schema version 后才能更新权威投影。

17. 测试和验收

17.1 callback

  • 能从最终 AIMessage.usage_metadata 提取 input/output Token。
  • 流式 chunk 携带 usage 后发生 error 时,能够从内存缓存生成已确认 usage 事件。
  • 同一进程并发调用不会串联 run metadata。
  • callback 异常不会改变模型返回结果。
  • 自定义 Provider 转为 OpenAI adapter 后仍保留 Provider Profile ID。
  • OpenAI、Anthropic 和两类 custom-compatible fixture 覆盖非流式、流式、缺 usage 和模型错误。
  • 没有 usage 时产生 unknown,而不是估算值。
  • Worker 在终态 callback 前硬退出时不伪造 unknown。
  • final/unknown 事件先原子写入 spool,不等待 HTTP。
  • 进程重启后可以重放未确认事件。
  • 多 worker 同时写相同 event key 时不会产生半文件、跨 payload 覆盖或 Windows 非法文件名。
  • stale lock、orphan temp 和 stale inflight 能在 lease 后恢复或清理。
  • spool 达软上限后 callback 仍 fail-open;heartbeat 可达时 Collector 持久化 first_loss_at,状态文件不可写时明确记录持久化边界。
  • 同步 spool 在 macOS、Linux、Windows 上满足 P95 20 ms、P99 100 ms 的额外延迟门槛。
  • on_llm_end 没有 metadata 时能按 run_id 取回 start 阶段缓存,结束后不泄漏缓存。
  • scope 优先级严格遵守显式 usage_scope、受测映射、main、unattributed 的顺序。
  • Tool Selector 使用 metadata model copy 后选择结果与未加统计标签时一致,主模型 metadata 不被污染。
  • selector model-copy 预检失败时不抛出、不改变工具选择路径、不产生错误归类事件,并上报 degraded 原因。

17.2 Collector 和 Projector

  • 同一个 event 上报两次只处理一次。
  • partial、Gateway、provider reconciled 和 provider only source 全部被 v1 schema 拒绝。
  • revision != 1 被 v1 schema 和数据库约束拒绝。
  • 相同 event ID、不同 payload 会进入 conflict,且不更新投影。
  • Token detail 不会重复加到总量。
  • 数据库事务失败时不会只写 Inbox 或只写 Projection。
  • (deployment_id, model_call_id) 能隔离不同 backend 的调用。
  • Python 和 TypeScript 对全部 accepted/rejected fixtures 结论一致。
  • 401/403、404、409、426、429、5xx 和超时分别执行契约规定的保留、quarantine 或重试动作。
  • 首次 accepted 事件的关联字段完整写入投影;duplicate 不修改,conflict 不静默覆盖。
  • event ID 字段与重算结果不一致时 rejected;payload hash 使用固定 JCS 结果。
  • 单次 input/output 之和超过安全整数时 rejected;聚合超过安全整数时仍精确返回十进制字符串。

17.3 主代理和后台任务

  • 主代理每次真实模型调用单独记录。
  • 同步子代理继承 thread 和 turn。
  • 异步子代理使用独立 model_call_id,同时关联 source session。
  • fallback 的每个实际尝试分别记录。
  • Tool Selector 和 summarizer 分别稳定归类,不混入 main。
  • 用户回合触发的 Memory Worker 继承 turn;后台 Memory 的 turn 为 null。
  • Scheduler 和 AutoSkills 使用固定 scope;未识别调用明确归类为 unattributed。

17.4 WebUI

  • 新消息生成新的 turn_id。
  • 新消息和 interrupt resume 都使用 submit options 顶层 metadata;config.metadata 和 configurable 中不保存 turn_id。
  • 页面刷新后的 interrupt resume 能从最后一条真实 HumanMessage 复用原 turn_id。
  • summarization marker 和异步完成信号不会被误选为 turn_id。
  • Token 归属不写 thread metadata,与标题、置顶和模型覆盖并发时不会造成字段丢失。
  • 线程汇总包含关联的异步子代理调用。
  • unknown 数量可见,不显示为零 Token。
  • 浏览器无法读取 sink token。
  • ingest routes 不要求 WebUI cookie,但缺少/错误 sink token 时拒绝;查询 routes 不增加用户、角色或 workspace ACL。
  • WebUI 全局认证开启时查询接口受现有登录保护,关闭时不额外增加 Token 统计权限层。
  • Collector 不可达或 schema 不兼容时显示明确状态,不显示为零。
  • 旧 backend 没有兼容 heartbeat 时显示“未启用或不支持”,不显示为零。
  • heartbeat 超过 45 秒显示离线,sender degraded 时显示统计可能不完整和首次丢失时间。
  • summary 超过 90 天返回 422,calls 稳定游标在相同时间戳下不漏项、不重项。
  • summary、calls 和所有分组的 Token/计数字段是十进制字符串,UI 使用 BigInt 格式化。
  • Token 明细面板支持线程、workspace 和全部来源三种范围;calls 使用稳定游标逐页加载,unknown 行明确显示 Unknown。
  • Token 明细面板在桌面和 390px 移动视口中无横向溢出、控件遮挡或 Token 列错位。
  • npm pack 后的 standalone 在 macOS、Linux、Windows 可以加载 SQLite 驱动。
  • 自定义 data_dir 下的 deployment ID、token、spool 和 database 路径在两个进程中一致。

18. 验收标准

  1. 一次包含多次主代理、子代理和 fallback 的回合,记录数等于真实模型调用数。
  2. 相同 UsageEvent 重放不会增加汇总 Token。
  3. EvoScientist-WebUI 重启后 Token 投影保持一致。
  4. Collector 或 callback 故障不影响 Agent 正常回答。
  5. 已触发终态 callback 但未返回真实 usage 的调用可以枚举为 unknown;终态前硬崩溃不在该承诺内。
  6. 异步子代理 Token 可以归属到启动它的主会话。
  7. Tool Selector、Memory 和异步子代理只增加统计 metadata/可空上下文,不改变模型与 Agent 行为。
  8. backend 退出前已写入 spool 的事件在重启后能够上报,且不重复累计。
  9. 一个 Collector 接收多个 deployment 的事件时不会混合主键或汇总归属。
  10. Python sender 与 TypeScript Collector 通过同一 schema 和 fixtures,协议不存在双重定义。
  11. unknown 调用可枚举且不进入 confirmed Token 总量;v1 不存在 disputed。
  12. Provider usage 缺失或格式不兼容时稳定降级为 unknown,不触发额外模型调用。
  13. v1 外部 Collector 只能接受 callback final,不能提交 Gateway 或供应商对账权威事实。
  14. Token 统计不新增用户、角色、workspace ACL 或多租户权限模型。
  15. 新消息和 resume 的 turn_id 均能从顶层 submit metadata 到达 callback、异步子代理和 Memory Worker。
  16. v1 revision 固定为 1,所有 Token 聚合超过 JavaScript 安全整数后仍保持精确。
  17. 同一 workspace 的真实路径/symlink 在 ws1 规则下身份一致,跨重启不会漂移。

19. 最终决策

2.0 采用:

EvoScientist-WebUI 管理协议、回合关联、事件接收、Token 权威投影和查询展示;EvoScientist 只运行轻量 UsageCaptureCallback,并透传 scope、异步和 Memory 归属上下文。

不采用:

  • 从聊天消息或 SSE 文本累计 Token。
  • 每个 Agent 各自实现统计中间件。
  • 将 partial、final 和 reconciliation 相加。
  • 将 unknown 当作零或估算值。
  • callback 上报失败时让模型调用失败。
  • 为实现零代码修改而强制所有 Provider 迁移到 WebUI LLM Gateway。
  • 在 v1 接受 partial、Gateway 或供应商对账 source。
  • 为 Token 统计新增用户、角色或 workspace 权限控制。

该边界兼顾了完整性和低侵入性:复杂业务集中在 EvoScientist-WebUI,EvoScientist 只承担无法从外部替代的模型调用观测与少量 metadata 透传职责。实施必须先通过阶段零契约门禁,再进入并行编码。