70 KiB
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 目标
- 覆盖所有真实模型调用,而不只是主代理最终回答。
- 同一个模型调用重复上报时只统计一次。
- 按线程、回合、Agent scope、Provider 和模型聚合,并保证 scope 归类可验证。
- 原始 usage 观测与权威 Token 投影分离。
- 供应商 usage 对账修正保留审计轨迹。
- EvoScientist 的模型执行、流式输出和 Agent 行为不受统计故障影响。
- 第一阶段尽量减少对 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 负责:
- 以常量时间比较服务端 sink token,并限制请求体大小。
- 验证 schema version、固定
source=callback_final、必填字段、字符串长度和 Token 非负安全整数范围。 - 按
event_id幂等接收,并检测相同 ID 的 payload 冲突。 - 在同一数据库事务中写 Event Inbox 并更新 Usage Projection。
- 返回明确的 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 的权威投影。
规则:
- 相同
event_id重放是 no-op。 - 同一调用只有
callback_final:1;重复事件只能是 duplicate 或 conflict,不能累加。 - Token detail 是 input/output 的子集,不能重复计入总量。
- 单次调用的
total_tokens口径为input_tokens + output_tokens;跨调用汇总使用任意精度整数,不能使用 JavaScript Number 或 SQLite 浮点total()。 - 供应商原始 total 单独保存,用于发现口径差异。
- 相同 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 排序,合并顺序固定为:
- 相同 event ID 和 payload hash 是 duplicate。
- 相同 event ID、不同 payload hash 进入 event conflict,不能更新投影。
- 首次 accepted 事件创建
model_usage;此后同一调用只能 duplicate 或 conflict,不存在 revision 覆盖。 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 内部任务执行:
- 按 Provider Profile 和时间窗口获取供应商 usage 记录。
- 使用
provider_request_id匹配本地调用。 - 生成内部
provider_reconciled事实,不经过 callback sink token 或外部 Collector route。 - 更新同一个
model_call_id的权威投影。 - 记录对账前后的 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.pystream/state.py- checkpoint schema
- Provider 请求和 retry 行为
16. 分阶段实施
阶段零:实施契约冻结
- 提交 JSON Schema、API 契约、spool 状态机和 accepted/rejected/projection/spool 共享 fixtures。
- Python Pydantic 与 TypeScript validator 对共享 fixtures 取得完全一致的结果。
- 用真实 callback metadata fixture 验证 main、subagent、Tool Selector selector-only model copy、summarizer、Memory、Scheduler 和 AutoSkills 的 scope 映射。
- 完成 Provider usage 兼容性矩阵,记录锁定 SDK 版本、usage 与 request ID 的真实位置。
- 完成 macOS、Linux、Windows 的
better-sqlite3安装、migration 和 standalone 启动 spike。 - 验证 data_dir、deployment ID、sink token、spool 和 database 在 Python/TypeScript 两端解析到相同绝对路径。
- 在三个平台完成 spool 每个崩溃点的 fault injection、多 worker claim/recovery 和本地文件系统语义测试。
- 完成同步 spool 的 1/4/16 worker 延迟基准并满足 P95/P99 门槛。
- 验证 ingest route 绕过 cookie proxy 后仍强制 sink token,查询 route 不增加 workspace ACL。
- 用真实
useStream.submit验证顶层 metadata 在 callback 和get_config()中一致,新消息/resume 都覆盖。 - 用跨平台 workspace fixtures 验证
ws1_算法和 deployment ID 并发创建。 - 验证
revision=1const、单次 Token 和安全、BigInt 聚合及十进制字符串 API。
阶段零是编码门禁。schema、API 状态矩阵、Projection 合并规则、spool 状态机、scope 标签和 Memory turn 归属未通过共享测试前,不并行实现 sender 与 Collector;阶段零通过后,各模块可以按契约独立开发。
阶段一:Token 事实链路
- WebUI 接入
better-sqlite3、migration 和跨平台 standalone 打包测试。 - WebUI 实现 capabilities、heartbeat/status、Collector、Inbox、conflict、Projection 和 summary API。
- EvoScientist 实现轻量 callback、durable spool、capability 探测、heartbeat 和 HTTP sender。
- 模型工厂完成 selector model-copy 预检后注入 callback,保留 Provider Profile 身份;失败时 Agent 正常运行且状态 degraded。
- WebUI 使用 human message ID 创建 turn_id,interrupt resume 从消息状态恢复。
- 增加 Tool Selector 显式 scope、异步子代理上下文和 Memory 可空 turn_id 透传。
- UI 展示线程和回合级 confirmed Token、unknown 数量及 Collector 能力状态。
- 验证 backend 和 WebUI 进程重启后的 spool 重放与幂等。
阶段一不强制 stream usage,也不实现供应商对账。
阶段二:异步归属和运行健康
- 增加 Collector 健康状态、spool 积压、首次丢失时间和 quarantine 监控。
- 扩大多 worker 压力测试,验证软上限和长期 stale inflight 回收。
- 增加 Inbox/quarantine 保留任务和备份恢复演练。
阶段三:供应商 usage 能力门禁和差异报告
- 记录当前 Provider Usage API 的字段和粒度能力;没有 request ID 时禁止逐调用 reconciliation。
- 通过数据库 migration 3 保存内部聚合 usage 观测和固定
aggregate-report-v1policy。 - 生成精确的 Provider 时间窗差异报告,保持
projection_updated=false。 - 外部
/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. 验收标准
- 一次包含多次主代理、子代理和 fallback 的回合,记录数等于真实模型调用数。
- 相同 UsageEvent 重放不会增加汇总 Token。
- EvoScientist-WebUI 重启后 Token 投影保持一致。
- Collector 或 callback 故障不影响 Agent 正常回答。
- 已触发终态 callback 但未返回真实 usage 的调用可以枚举为 unknown;终态前硬崩溃不在该承诺内。
- 异步子代理 Token 可以归属到启动它的主会话。
- Tool Selector、Memory 和异步子代理只增加统计 metadata/可空上下文,不改变模型与 Agent 行为。
- backend 退出前已写入 spool 的事件在重启后能够上报,且不重复累计。
- 一个 Collector 接收多个 deployment 的事件时不会混合主键或汇总归属。
- Python sender 与 TypeScript Collector 通过同一 schema 和 fixtures,协议不存在双重定义。
- unknown 调用可枚举且不进入 confirmed Token 总量;v1 不存在 disputed。
- Provider usage 缺失或格式不兼容时稳定降级为 unknown,不触发额外模型调用。
- v1 外部 Collector 只能接受 callback final,不能提交 Gateway 或供应商对账权威事实。
- Token 统计不新增用户、角色、workspace ACL 或多租户权限模型。
- 新消息和 resume 的 turn_id 均能从顶层 submit metadata 到达 callback、异步子代理和 Memory Worker。
- v1 revision 固定为 1,所有 Token 聚合超过 JavaScript 安全整数后仍保持精确。
- 同一 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 透传职责。实施必须先通过阶段零契约门禁,再进入并行编码。