1. 为什么需要用量统计?
假设我们让 Agent 修复一个登录问题:“用户输错密码后,页面没有出现错误提示。”Agent 需要读取相关代码、分析原因、修改错误处理,再运行测试。这个过程可能多次调用模型;委派子代理检查接口、生成摘要来压缩历史,也会产生额外的模型调用。
任务完成后,我们除了检查修改结果,还想知道:Agent 完成这项任务消耗了多少 Token?这些消耗分别发生在哪些环节?按模型单价估算,大约产生了多少费用?
要回答这些问题,统计需要从每次模型调用开始。模型服务返回本次调用的用量,Agent 保存这些报告,再把主 Agent、子代理和摘要调用的消耗汇总,按实际使用的模型估算费用。
Ink 按会话累计用量。这里假设我们新建一个会话来排查登录问题,因此示例中的累计值对应这次排查的消耗。如果之后继续在这个会话中处理其他任务,新的用量也会加入同一个总数。
下面先看登录排查中的一份调用报告,再沿着它的保存、汇总和计价过程,解释界面上的数字从哪里来。
2. 一次模型调用会返回什么用量?
回到登录排查。Agent 读到相关代码后,会调用模型分析错误处理。这次调用完成时,模型服务可以在返回回复的同时,提供本次调用的用量信息。我们先看 Ink 接入层整理后的一份报告。
2.1 先看一份调用报告
以 DeepSeek 的 Chat Completions 接口为例,假设这次调用得到下面这份由 Ink 整理后的报告。本文的 Token 数量和后面的单价均为教学数据,不是真实任务日志或服务报价。
{
"inputTokens": 1700,
"outputTokens": 500,
"cacheReadTokens": 8300
}
Agent 往往需要多次调用模型,系统指令、工具定义和已有对话也可能反复发送。模型服务可以缓存请求开头重复内容的处理结果,供后续请求命中时复用,从而减少重复计算。
Ink 的统一用量结构有四个字段。这份报告出现了前三个,第四个是可选字段:
inputTokens:普通输入量。 本例中,指本次输入里没有命中缓存的 Token 数量。outputTokens:输出量。 本次模型生成、由服务报告的 Token 数量,可以包含分析、工具调用参数或最终回答;不能只数界面上可见的回答文字。cacheReadTokens:缓存读取量。 本次输入中,命中已有缓存、复用其处理结果的 Token 数量。cacheWriteTokens:缓存写入量。 服务单独报告用于创建缓存的输入量、且 Ink 的适配器支持转换时,记录在这里。
因此,上面这次调用的完整输入是 1,700 + 8,300 = 10,000 Token,输出另计 500 Token。inputTokens 只表示普通输入,不能把它当作完整输入量。
缓存命中的输入可能采用更低单价,部分模型服务还会对创建缓存单独计费。因此,完整输入量和输出量只能说明处理了多少内容;要估算费用,还需要区分普通输入、缓存读取,以及服务另行计费的缓存写入,分别套用对应单价。
不同模型服务提供商(如 OpenAI、Anthropic、DeepSeek)的 API,在用量字段的命名和含义上并不完全一致。Ink 的模型接入层负责转换。DeepSeek 这个接口报告输入的缓存命中与未命中量,没有单独的缓存写入计数,所以这里没有 cacheWriteTokens。这不表示服务没有建立缓存,也不能把未命中输入直接当作缓存写入量。DeepSeek 的接口定义说明了这些原始字段。
报告描述的是这一次模型调用,后续每次调用也有各自的报告。统计会话用量时,需要累计这些报告。
这里的数量来自模型服务,不是 Agent 根据文件长度或回答字数计算出来的。Token 是模型处理内容的计量单位,不能直接当作汉字数或字符串长度。
2.2 登录排查中的用量怎样变化?
把这份报告放回完整的登录排查过程:模型先请求读文件,Agent 执行读取;模型拿到代码后请求修改,Agent 执行修改;接着运行测试,最后说明修复结果。
DeepSeek 的模型 API 默认启用输入缓存。Ink 发送请求后,DeepSeek 会自动建立缓存,并在后续请求到来时判断能否复用。当后续请求完整匹配已经建立的缓存前缀时,系统指令、用户需求以及之前的对话都可能命中缓存,从而减少重复计算,降低这部分输入的费用。DeepSeek 的缓存文档也说明,缓存构建需要时间,命中是尽力而为的,重复发送内容并不保证下一次就会命中。
下面用四次模型调用,展示登录排查过程中的 Token 用量变化。点击各次调用,可以看到输入由哪些内容组成、哪些内容命中了缓存,以及模型输出和工具结果怎样进入下一次请求。
第 1 次:发送排查任务,模型回复请求读文件
本次请求
用户任务:用户输错密码后,页面没有出现错误提示。
本次发送的完整历史(2 项)
- 系统指令与工具定义8,000未命中
- 用户任务(登录排查)200未命中
本次输入用量
- 普通输入
- 8,200
- 缓存读取
- 0
- 完整输入
- 8,200
本次模型回复
读取登录组件代码,检查错误提示的实现。
read({"path":"src/pages/Login.tsx"})本次模型输出
100 TokenAgent 执行读文件工具
返回登录组件代码,共 1,700 Token。
看第 2 次输入如何形成截至第 1 次累计 图中调用,单位 Token
- 普通输入
- 8,200
- 缓存读取
- 0
- 输出
- 100
DeepSeek 原始字段与 Ink 统一字段
DeepSeek 报告字段示意
{
"prompt_tokens": 8200,
"prompt_cache_hit_tokens": 0,
"prompt_cache_miss_tokens": 8200,
"completion_tokens": 100
}Ink 统一字段
{
"inputTokens": 8200,
"outputTokens": 100
}第 2 次:发送登录组件代码,模型回复分析结果并请求修改
本次请求
本次发送的完整历史(4 项)
- 系统指令与工具定义8,000命中
- 用户任务(登录排查)200命中
- 第 1 次模型输出:读取文件的工具调用100命中
- 读文件结果:登录组件代码1,700未命中
本次输入用量
- 普通输入
- 1,700
- 缓存读取
- 8,300
- 完整输入
- 10,000
本次模型回复
错误被捕获后没有写入页面状态。
调用 edit,为错误处理添加页面提示。本次模型输出
500 TokenAgent 执行修改工具
返回文件修改结果,共 300 Token。
看第 3 次输入如何形成截至第 2 次累计 图中调用,单位 Token
- 普通输入
- 9,900
- 缓存读取
- 8,300
- 输出
- 600
DeepSeek 原始字段与 Ink 统一字段
DeepSeek 报告字段示意
{
"prompt_tokens": 10000,
"prompt_cache_hit_tokens": 8300,
"prompt_cache_miss_tokens": 1700,
"completion_tokens": 500
}Ink 统一字段
{
"inputTokens": 1700,
"outputTokens": 500,
"cacheReadTokens": 8300
}第 3 次:发送文件修改结果,模型回复请求运行测试
本次请求
本次发送的完整历史(6 项)
- 系统指令与工具定义8,000命中
- 用户任务(登录排查)200命中
- 第 1 次模型输出:读取文件的工具调用100命中
- 读文件结果:登录组件代码1,700命中
- 第 2 次模型输出:分析与修改请求500命中
- 修改工具的执行结果300未命中
本次输入用量
- 普通输入
- 300
- 缓存读取
- 10,500
- 完整输入
- 10,800
本次模型回复
运行登录相关测试,验证修改结果。
bash({"command":"npm test -- login"})本次模型输出
100 TokenAgent 执行测试工具
返回测试结果,共 600 Token。
看第 4 次输入如何形成截至第 3 次累计 图中调用,单位 Token
- 普通输入
- 10,200
- 缓存读取
- 18,800
- 输出
- 700
DeepSeek 原始字段与 Ink 统一字段
DeepSeek 报告字段示意
{
"prompt_tokens": 10800,
"prompt_cache_hit_tokens": 10500,
"prompt_cache_miss_tokens": 300,
"completion_tokens": 100
}Ink 统一字段
{
"inputTokens": 300,
"outputTokens": 100,
"cacheReadTokens": 10500
}第 4 次:发送测试结果,模型回复修复说明
本次请求
本次发送的完整历史(8 项)
- 系统指令与工具定义8,000命中
- 用户任务(登录排查)200命中
- 第 1 次模型输出:读取文件的工具调用100命中
- 读文件结果:登录组件代码1,700命中
- 第 2 次模型输出:分析与修改请求500命中
- 修改工具的执行结果300命中
- 第 3 次模型输出:运行测试的工具调用100命中
- 测试工具的执行结果600未命中
本次输入用量
- 普通输入
- 600
- 缓存读取
- 10,900
- 完整输入
- 11,500
本次模型回复
已补充密码错误时的页面提示,登录相关测试通过。本次模型输出
300 TokenAgent 展示最终回答
任务结束,本次没有新的工具执行。
截至第 4 次累计 图中调用,单位 Token
- 普通输入
- 10,800
- 缓存读取
- 29,700
- 输出
- 1,000
DeepSeek 原始字段与 Ink 统一字段
DeepSeek 报告字段示意
{
"prompt_tokens": 11500,
"prompt_cache_hit_tokens": 10900,
"prompt_cache_miss_tokens": 600,
"completion_tokens": 300
}Ink 统一字段
{
"inputTokens": 600,
"outputTokens": 300,
"cacheReadTokens": 10900
}缓存写入:DeepSeek 未单独报告。
图中第二次调用对应第 2.1 节的报告。模型生成的工具调用计入当次输出;工具执行结果在后续请求发送给模型时,计入那次调用的输入。 同一段历史随多次请求反复发送,也会在各次调用中分别计数。因此,统计会话消耗时,需要累计各次模型调用返回的用量报告。
缓存读取记录的是本次请求实际命中的输入量,不能把此前各次缓存写入量相加,当作本次读取量。
图中的加法省略了消息封装等细节,输出计数也可能包含推理内容;实际用量和缓存命中情况,以模型服务返回的报告为准。
接下来,看看这份用量报告怎样随模型回复保存到会话中。
3. 一份用量报告怎样进入会话记录?
3.1 从服务响应中取得并统一用量
沿用登录排查的第二次调用。DeepSeek 在 Ink 中复用 Chat Completions 适配器,第 2 节展示的报告就是经过这层转换后的结果。
在这条 DeepSeek 接入路径中,由于请求启用了流式输出,回复内容会分多个片段返回;本次调用的完整用量统计则在流末尾给出。设置 include_usage: true 后,中间片段的 usage 为 null;[DONE] 前最后一个 JSON 片段包含本次调用的完整用量。[DONE] 本身只是流结束标记。DeepSeek 的接口文档说明了这些返回规则。
Ink 的适配器在请求中设置 stream_options: { include_usage: true },并逐片检查响应。拿到非空的原始用量对象后,再转换成 Ink 内部的 Usage 结构:
// src/provider/openai-completions.ts
// createOpenAICompletionsProvider 返回的 stream:读取并转换用量。
// 省略请求发送与其他回复状态的初始化。
const usage: Usage = { inputTokens: 0, outputTokens: 0 };
// ……省略停止原因、缺报标记等状态的初始化……
for await (const sse of abortSafe(parseSse(response.body), request.signal)) {
if (request.signal?.aborted) return;
const chunk = sse.data as StreamChunk;
const choice = chunk.choices?.[0];
// ……省略文字、工具调用及停止原因的处理……
const rawUsage = chunk.usage ?? choice?.usage;
if (rawUsage != null) {
// ……省略数值字段检查和 sawUsage 标记……
const cached =
rawUsage.prompt_tokens_details?.cached_tokens ??
rawUsage.prompt_cache_hit_tokens ??
rawUsage.cached_tokens ??
0;
usage.inputTokens = Math.max(
usage.inputTokens,
(rawUsage.prompt_tokens ?? 0) - cached
);
usage.outputTokens = Math.max(
usage.outputTokens,
rawUsage.completion_tokens ?? 0
);
if (cached > 0) {
usage.cacheReadTokens = Math.max(usage.cacheReadTokens ?? 0, cached);
}
}
}
循环外的 usage 用来保存这一次模型调用的用量。对于这条 DeepSeek 接入路径,中间片段的 usage: null 不会进入转换分支;末尾片段携带完整用量对象后,适配器才执行其中的字段转换。
在 DeepSeek 的报告中,prompt_tokens 是完整输入,prompt_cache_hit_tokens 是缓存命中量。第二次调用的 10,000 输入减去 8,300 缓存命中,得到 1,700 普通输入;输出另记为 500。这就形成了第 2.1 节的 inputTokens: 1700、cacheReadTokens: 8300 和 outputTokens: 500。
Math.max 在同一次响应内保留已收到的较大值,属于适配器的兼容处理,并不表示每个片段都会返回用量。不同模型调用的用量仍需分别累计,具体规则留到第 4 节讨论。
DeepSeek 这条接入路径没有另外填入 cacheWriteTokens。Anthropic、Codex Responses 等适配器也统一产出 Usage;具体有哪些字段,取决于模型服务返回了什么,以及适配器支持哪些转换。
3.2 把单次用量随模型回复保存
一次模型调用正常结束后,适配器把整理后的 usage 与本次回复的内容放进同一条助手消息,通过 Ink 内部的 message_end 事件交给执行循环:
// src/provider/openai-completions.ts
// createOpenAICompletionsProvider 返回的 stream:产出完整消息。
// 此前已收集回复 blocks、整理 usage,并检查流是否正常结束。
yield {
type: "message_end",
message: {
role: "assistant",
blocks,
usage,
stopReason,
// 未收到用量时附加标记,见 3.3。
...(sawUsage ? {} : { usageMissing: true as const }),
},
};
blocks 保存本次生成的分析、工具调用等内容,usage 保存这一次调用的用量。每次正常结束的模型调用都会形成自己的助手消息,收到的用量报告随消息保存。 这也包括模型发起工具调用的中间回复。例如,图中第二次调用生成分析和修改请求后,就会保存那份 1,700 普通输入、8,300 缓存读取和 500 输出的报告。
负责读取模型响应的 streamAssistant 从结束事件中取出消息,附上产生这条回复的模型信息后,返回给执行循环:
// src/core/loop.ts
// streamAssistant:处理 message_end 的分支。
if (event.type === "message_end") {
// ……省略该分支前面的记账、缺报处理与事件通知……
return {
...event.message,
model: request.model,
...(modelReference !== undefined && { modelReference }),
};
}
...event.message保留回复内容、这次调用的usage和停止原因等字段。model记录这次请求使用的模型 ID。modelReference在调用方提供时写入,保存provider/modelId形式的完整模型引用,供后续按模型分组和查找单价。
执行循环中的 runAgentLoop 收到这条消息后,将它加入内存历史,再通过 onMessage 交给 Runner:
// src/core/loop.ts
// runAgentLoop:assistant 是 streamAssistant 返回的单次调用消息。
// 省略此前的请求构造、流读取和中断返回检查。
history.push(assistant);
turns++;
onMessage?.(assistant);
启用会话保存时,Runner 注册的回调会将这条消息交给会话存储对象:
// src/runner.ts
// runTurnInner:传给 runAgentLoop 的 onMessage 回调,展示保存部分。
// 其余运行参数及保存之后的扩展事件通知省略。
onMessage: (message) => {
this.sessionStore?.appendMessage(message);
// ……此后的扩展事件通知省略……
},
appendMessage 把整条消息包装成 type: "message" 的记录,再以 JSONL 格式写入会话文件。沿用上面的教学用量,以模型引用 deepseek/deepseek-flash 为例,记录中的相关字段如下;为了便于阅读,这里展开显示,并省略回复内容、记录标识、时间等字段:
{
"type": "message",
"message": {
"role": "assistant",
"usage": {
"inputTokens": 1700,
"outputTokens": 500,
"cacheReadTokens": 8300
},
"modelReference": "deepseek/deepseek-flash"
}
}
会话记录中的 message.usage 是这一次模型调用的报告。 后续汇总会读取各条记录中的用量,再做加法;message.modelReference 保存产生这条回复的完整模型引用,供第 5 节按对应模型的单价估算费用。
图中第二次回复会先保存,之后 Agent 才执行修改工具。界面收到的流式文字与持久化消息是不同阶段;会话保存的具体实现已在第 05 篇展开。
3.3 没收到用量报告时怎样记录
模型服务可能返回正常回复,却没有返回用量。适配器仍会保留初始化的 inputTokens: 0 和 outputTokens: 0,以保持消息结构完整,但这些零值不能证明调用没有消耗。
适配器用前面代码中的 sawUsage 记录是否读到了可识别的数值型用量字段。明确返回的数值 0 是已知结果;没有读到这些字段时,适配器会在助手消息上附加 usageMissing: true。
这个标记和 usage 一起进入会话记录。后续汇总保留已知数量,同时传递缺报状态,使界面能够提示统计不完整,而不会把初始化的零值当作确实没有消耗。
4. 多次调用怎样汇总成会话用量?
上一节已经把每次调用的用量报告保存进会话记录。回到登录排查,四次主调用留下了四份报告。Ink 怎样把它们加起来?如果会话中还有生成摘要的模型调用和子代理调用,这些消耗又怎样加入?
4.1 单次报告怎样累加成会话用量?
Ink 状态栏显示的是整个会话的累计用量,包括同一会话各分支留下的记录。如果继续在这个会话中处理其他任务,新的消耗也会加入总数,不会在每次用户输入后清零。
会话分支是指一个会话中的不同对话路径。例如,Agent 已经从前端渲染逻辑分析了几轮,但你想回到较早的对话位置,改从“接口是否返回错误信息”重新排查。可以用 /tree 打开会话历史,选择较早的一条回复,再输入新的排查要求。后续对话会沿新的路径继续,原来的路径仍然保留。切换会话分支不会自动回滚工作区代码。
Runner.usageTotals() 从会话存储对象取得全部记录,交给 usageTotalsTracker 汇总。
先看主 Agent 的回复。汇总器用一个对象保存这部分累计用量,其中分别记录普通输入、输出、缓存读取和缓存写入的合计。这个对象在源码中叫 state.parent,parent 表示主 Agent,与后面子代理的 child 对应。
每读到一条主 Agent 的 assistant 消息,汇总器就把其中的 message.usage 按字段累加到 state.parent 中:
// src/core/usage-totals.ts
// applyEntry:节选主 Agent 回复的处理,省略工具结果与摘要记录分支。
if (entry.type === "message") {
const message = entry.message;
if (message.role === "assistant") {
addUsage(state.parent, message.usage);
if (message.usageMissing === true) state.incomplete.parent = true;
// ……省略按模型分组的处理,见第 5 节……
}
// ……省略 toolResult 分支……
}
addUsage 按字段分别做加法,缓存字段没有报告时按零参与累计;第 3 节的缺报标记则另外保留:
// src/core/usage-totals.ts
// addUsage:将一份用量加入指定的汇总项。
function addUsage(
bucket: UsageBucket,
usage: {
inputTokens: number;
outputTokens: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
}
): void {
bucket.inputTokens += usage.inputTokens;
bucket.outputTokens += usage.outputTokens;
bucket.cacheReadTokens += usage.cacheReadTokens ?? 0;
bucket.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
bucket.calls += 1;
}
把第 2.2 节的四份报告代入,主 Agent 的累计用量就是:
普通输入:8,200 + 1,700 + 300 + 600 = 10,800
缓存读取: 0 + 8,300 + 10,500 + 10,900 = 29,700
输出: 100 + 500 + 100 + 300 = 1,000
累计用量是各次调用报告的加法。 第 2.2 节中的登录组件代码只读取了一次,却随第二、第三和第四次请求分别发送,所以三次都会计入输入:第二次属于普通输入,第三、第四次属于缓存读取。汇总时保留这些分类,不会把重复发送的内容去重后只算一次。
4.2 生成摘要和运行子代理的用量怎样加入?
如果这个会话只有上面四次调用,parent 就覆盖了全部消耗。但生成压缩摘要、生成分支摘要,以及运行子代理,也会产生额外的模型调用。
上下文压缩时,为整理较早历史生成的内容称为压缩摘要;切换分支时,为离开的对话路径生成的总结称为分支摘要。这里统计的是生成这些摘要的模型调用用量,包含输入、输出等计数。
这些报告没有都保存在主会话的普通 assistant 消息中:主会话生成压缩摘要和分支摘要的用量,分别随 compaction 和 branchSummary 记录保存;子代理在自己的执行循环中调用模型,再把本次运行的用量交回主会话。
因此,汇总器需要读取三类记录:
| 工作来源与汇总项 | 从主会话的哪里读取 |
|---|---|
主 Agent 回复:parent | assistant 消息的 message.usage |
主会话生成摘要:summarizer | compaction、branchSummary 记录的 usage |
子代理本次运行:child | 工具结果中 TaskRecord.usage |
继续登录排查。假设主 Agent 把“检查登录接口与错误提示处理”交给一个子代理。下面用一组教学报告,观察子代理检查代码、中途压缩自身上下文、再继续分析时,用量怎样被记下。
Ink 在每次 runSubagent 开始时,先创建一个用量账本。这里的账本就是保存本次运行累计用量的对象;每收到一份报告,就把数量加进去。假设这次依次收到三份报告,单位均为 Token:
| 发生的调用 | 普通输入 | 缓存读取 | 输出 |
|---|---|---|---|
| 第一次分析:检查登录接口 | 1,200 | 6,000 | 300 |
| 上下文压缩:生成压缩摘要 | 2,000 | 0 | 200 |
| 第二次分析:确认错误提示的处理 | 1,800 | 0 | 400 |
示例说明:这里直接假设中途满足了压缩条件,首次请求命中了已有的 6,000 Token 缓存。表中数量只演示记账,不模拟压缩阈值与保留窗口;这三份报告会一起计入第 4.4 节的会话合计。
假设压缩时,第一次分析的原始回复被压缩摘要替换,不再放进后续请求的上下文。如果结束时才遍历当前上下文,就可能找不到那条回复及其用量。账本在报告到达时已经记下了这笔消耗,压缩上下文不会把它扣掉。 生成压缩摘要的那次模型调用,产生的 2,000 普通输入和 200 输出,也会记入同一本账。
任务结束时,两次分析合计为 3,000 普通输入、6,000 缓存读取和 700 输出;再加上这次上下文压缩的用量,整个子代理本次运行的合计就是 5,000 普通输入、6,000 缓存读取和 900 输出。
子代理除了交回检查结论,还会把这份合计写入 TaskRecord.usage,随工具结果保存到主会话。下面展示工具结果消息的 results 数组中的一个对象,只保留检查结论和用量记录,省略其他字段:
{
"content": "接口已返回错误信息,登录组件没有把它显示出来。",
"taskRecord": {
"usage": {
"inputTokens": 5000,
"outputTokens": 900,
"cacheReadTokens": 6000
}
}
}
这两个字段分别交给不同的处理流程:
content交给主 Agent。 这段检查结论后续发送给主模型时,会计入那次主调用的输入。taskRecord.usage交给会话汇总器。 汇总器把其中的数量加入代表子代理累计用量的child;结构化taskRecord本身不进入模型请求。
主会话已经从这份记录拿到子代理本次运行的合计,不再扫描子会话文件。子代理上下文压缩的用量也已经包含在这份合计中,不会再加入主会话的 summarizer。
每次子代理运行还有一个 attemptId,用来区分不同的运行。即使同一次运行的结果重复出现在会话记录中,汇总器也只加入一次。这里识别的是同一次运行的重复记录,不是不同请求中重复发送的内容。
如果稍后恢复这个子代理继续检查,新一轮运行会从空账本开始,并使用新的 attemptId。上面三份报告已经交回,不再重复加入;恢复后新收到的报告才记入新账本。旧上下文再次发送给模型产生的输入用量,属于这次新运行。恢复执行的过程见第 10 篇上篇。
主 Agent 回复
- 回复携带统一的 usage
- 保存 assistant 消息
汇入 parent
主会话生成摘要
- 生成压缩摘要或分支摘要,返回 usage
- 成功后保存摘要记录
汇入 summarizer
子代理本次运行
- 账本累计任务回复与上下文压缩的用量
- 随工具结果保存 TaskRecord
汇入 child
按来源累计数量,同时按产生用量的模型分组,用于费用估算。
从上向下阅读。子代理上下文压缩的用量已经包含在 child 中,不再计入主会话的 summarizer。图示表示记录成功保存后的归属;缺失记录的限制见第 4.5 节。
最后,汇总器把 parent、child 和 summarizer 的四类 Token 数量分别相加,得到整个会话的 total。
4.3 上下文压缩、分支切换和重新打开会话,会怎样影响累计用量?
这里讨论的是整个主会话的累计用量 total,包括主 Agent 回复的用量、主会话生成摘要的用量,以及子代理交回的用量。
回到登录排查。四次主调用的报告已经保存。如果之后压缩主会话的上下文、切换分支,或关闭后重新打开会话,这些操作会怎样影响累计值?
| 操作 | 对主会话累计用量的影响 |
|---|---|
| 压缩主会话的上下文 | 旧消耗保留;调用模型生成压缩摘要产生的新用量也加入统计 |
| 切换主会话分支 | 仍包含旧分支的消耗;若调用模型生成分支摘要,其用量也加入统计 |
| 重新打开已保存的主会话 | 根据已有记录重新计算;同样的记录得到同样的 Token 合计 |
上下文压缩会缩短后续请求携带的历史,但原来的会话记录仍然保留,所以过去的消耗不会被扣掉。生成压缩摘要的模型调用本身也会产生用量,不能把“上下文变短”理解为“累计用量变少”。
重新打开会话并读取历史,本身不需要调用模型。Ink 从已保存的记录重新算出累计用量,不会把上次显示的总数再加一遍。
4.4 合并所有来源,得到会话总量
第 4.1 节已经算出四次主调用的合计,第 4.2 节的子代理也交回了本次用量。再假设主会话完成了一次上下文压缩,生成压缩摘要的模型调用消耗 4,000 普通输入和 300 输出,缓存读取为零,就可以把各来源放在一起汇总。这些是额外补充的教学记录,不插入第 2.2 节的四次调用之间;本例假设所有报告均已收到并保存:
| 工作来源 | 普通输入 | 缓存读取 | 输出 |
|---|---|---|---|
| 主 Agent 的四次回复 | 10,800 | 29,700 | 1,000 |
| 主会话上下文压缩 | 4,000 | 0 | 300 |
| 子代理任务回复 | 3,000 | 6,000 | 700 |
| 子代理上下文压缩 | 2,000 | 0 | 200 |
| 会话合计 | 19,800 | 35,700 | 2,200 |
本例各份报告都没有单独的缓存写入量。汇总器将这个可选字段按零参与加法,表示没有收到这个类别的数量,不能据此断言服务从未建立缓存。
表中把第 4.2 节的子代理用量拆成任务回复与上下文压缩两行,方便核对;实际进入 child 的仍是二者合计:5,000 普通输入、6,000 缓存读取和 900 输出。子代理上下文压缩的用量已经包含在内,只累计一次。
4.5 哪些消耗可能没有进入统计?
上述会话合计以报告已经收到并保存为前提。如果主 Agent 的回复未能成功写入会话记录,或进程被强制终止、子代理账本来不及写入 TaskRecord,对应的消耗就可能没有进入会话汇总。
主会话生成压缩摘要或分支摘要时,用量会随摘要保存到相应的 compaction 或 branchSummary 记录中。如果生成流程最终失败,没有保存相应记录,这部分消耗就可能遗漏。
5. 怎样根据 Token 用量估算费用?
第 4 节已经汇总了会话用量。要估算费用,还需要知道这些用量由哪些模型产生,以及各类 Token 的对应单价。下面先用一次调用说明计价方法,再计算整个会话的费用。
5.1 一次模型调用的费用怎样计算?
对同一种模型,普通输入、输出、缓存读取和缓存写入可能采用不同单价。假设单价的单位都是“美元/百万 Token”,一次调用的估算费用就是:
费用 =(普通输入 × 输入单价
+ 输出 × 输出单价
+ 缓存读取 × 缓存读取单价
+ 缓存写入 × 缓存写入单价)÷ 1,000,000
假设主 Agent 使用模型 A,各类 Token 的教学单价如下。这些价格只用于演算,不代表实际报价:
| Token 类别 | 单价(美元/百万 Token) |
|---|---|
| 普通输入 | 2 |
| 缓存读取 | 0.2 |
| 输出 | 8 |
本例没有单独的缓存写入计数,并假定模型 A 不另收缓存创建费。将第 2.1 节的报告,也就是登录排查第二次调用的数量,代入公式:
普通输入:1,700 × 2 / 1,000,000 = $0.00340
缓存读取:8,300 × 0.2 / 1,000,000 = $0.00166
输出: 500 × 8 / 1,000,000 = $0.00400
本次估算: $0.00906
在这组教学单价下,8,300 个缓存读取 Token 比 1,700 个普通输入 Token 的费用还低。因此,估算费用既要看 Token 数量,也要看它所属的类别和对应单价。
首次调用没有命中缓存,8,200 普通输入和 100 输出按同一组单价计算,估算费用为 $0.0172。
是否单独计算缓存写入费用,要以模型服务提供商的计费规则和报告为准。如果另有写入计数和对应费率,就按这一类别计算。Ink 的统一字段已将普通输入与缓存用量分开,不能先把完整输入全按普通输入计价,再叠加缓存读写费用。
5.2 同一会话使用多个模型,费用怎样计算?
假设用户在同一主会话中,先用模型 A 解决第一个问题,再切换到模型 B 解决第二个问题,最后用模型 C 解决第三个问题。这些任务都由主 Agent 完成,但各次调用使用的模型不同,单价也可能不同。
每个问题可能包含多次模型调用。计算费用时,先分别汇总 A、B、C 各次调用的用量,再对每组套用第 5.1 节的公式,最后将三组费用相加。
下面另用一组不含缓存费用的教学数据演算,单价单位仍为“美元/百万 Token”。这组数据只用于说明中途切换模型的计价,不计入第 4.4 节的登录排查会话:
| 模型 | 累计普通输入 | 累计输出 | 输入单价 | 输出单价 |
|---|---|---|---|---|
| A | 10,000 | 1,000 | 2 | 8 |
| B | 20,000 | 1,000 | 1 | 4 |
| C | 30,000 | 1,000 | 3 | 12 |
分别计算各模型的费用,再合计:
模型 A:(10,000 × 2 + 1,000 × 8) / 1,000,000 = $0.028
模型 B:(20,000 × 1 + 1,000 × 4) / 1,000,000 = $0.024
模型 C:(30,000 × 3 + 1,000 × 12) / 1,000,000 = $0.102
会话合计:$0.028 + $0.024 + $0.102 = $0.154
即使当前已经切换到 C,A、B 的消耗仍按各自模型的单价计算。如果报告中还有缓存读取或缓存写入,也在对应模型组内按各自单价计价。
Ink 通过每条回复保存的完整模型身份来实现这种分组。modelReference 的形式为 provider/modelId,同时包含模型服务提供商和模型 ID。保留提供商,是因为不同提供商即使使用相同模型 ID,也未必采用相同费率。
汇总器按这个身份生成 byModel。计算金额时,Ink 通过 costFor 查询每组模型的单价:先查对应提供商的模型目录,目录中没有费率时,再查该提供商的静态价格表。这里使用的是 Ink 当前可用的价格信息,模型目录可能来自本地缓存。
源码中的 priceUsageTotals 遍历 byModel,为每组取得单价后累加金额:
// src/core/usage-totals.ts
// priceUsageTotals:已为当前模型分组查到 rates 后的金额累加。
// 外层模型遍历、未知单价分支与返回包装省略。
usd +=
(model.bucket.inputTokens * rates.input +
model.bucket.outputTokens * rates.output +
model.bucket.cacheReadTokens * rates.cacheRead +
model.bucket.cacheWriteTokens * rates.cacheWrite) /
1_000_000;
退出后重新打开会话,Ink 根据已保存的用量与模型身份重新建立这些分组,再分别查价计算费用。
主 Agent 与子代理使用不同模型时,也遵循同一套分组计价规则。子代理用量按本次运行绑定的模型身份加入相应分组。回到第 4.4 节的登录排查会话,假设主 Agent 的四次调用及主会话生成压缩摘要的调用使用 A,子代理的任务回复与生成压缩摘要的调用使用 B,则可以得到:
| 模型 | 对应的工作来源 | 普通输入 | 缓存读取 | 输出 |
|---|---|---|---|---|
| A | 主 Agent 回复、主会话生成压缩摘要的调用 | 14,800 | 29,700 | 1,300 |
| B | 子代理任务回复、子代理生成压缩摘要的调用 | 5,000 | 6,000 | 900 |
这仍是第 4.4 节的同一批消耗,只是改为按模型分组。模型 A 沿用第 5.1 节的教学单价,模型 B 的普通输入、缓存读取和输出单价分别假定为 1、0.1、4 美元/百万 Token,同样不另收缓存创建费,则会话费用是:
模型 A:(14,800 × 2 + 29,700 × 0.2 + 1,300 × 8)
/ 1,000,000 = $0.04594
模型 B:( 5,000 × 1 + 6,000 × 0.1 + 900 × 4)
/ 1,000,000 = $0.00920
会话合计:$0.04594 + $0.00920 = $0.05514
5.3 为什么估算金额可能与实际账单不同?
这笔金额是按已记录用量和 Ink 当前可用费率换算的,与模型服务提供商的实际账单可能存在差异。
-
用量或计费项目不完整。
第 3 节提到的报告缺失、第 4.5 节提到的记录遗漏,都会影响金额;适配器也可能尚未覆盖全部计费项目。例如,OpenAI 的缓存文档列出了部分模型的缓存写入计费,而本文涉及的 Ink 两个 OpenAI 适配器尚未转换对应计数。
此外,当前计价为每个模型使用一组固定费率,尚未覆盖按单次请求长度变化的阶梯计价。
-
部分用量无法定价。
如果记录缺少完整模型身份,或者 Ink 查不到对应单价,就保留 Token 数量并标为未定价,金额只合计能查到价格的部分。
-
采用的是查看时可用的费率。
Ink 在查看统计时查询价格,没有将每次调用时的单价随用量报告一起保存。因此,重新打开同一会话时,Token 数可以保持一致,估算金额却可能随模型目录中的费率变化。如果记录和费率都未变化,重新加载后的估算金额也相同。
-
订阅套餐的计费方式不同。
目录中可能保留参考费率,但按 Token 换算出的金额不代表本次实际扣款,也不等于订阅月费;实际费用取决于套餐的计费规则。
6. 怎样理解界面上的统计结果?
报告已经按来源累计,并按模型单价换算成金额。最后看这些结果怎样显示,以及界面上的用量和金额各自表示什么。
6.1 会话用量与金额怎样显示?
回到前面的完整例子:累计普通输入 19,800、缓存读取 35,700、输出 2,200,估算金额为 0.05514 美元。假设所有报告与教学单价均已知,底部状态栏的用量和金额部分会是:
↑20k ↓2.2k R36k CH64.3% $0.055
这些符号分别表示:
| 显示 | 读法 |
|---|---|
↑20k | 会话累计的普通输入量,不含另列的缓存量;19,800 在此按整数千位显示为 20k |
↓2.2k | 会话累计输出量 |
R36k | 会话累计缓存读取量;35,700 按整数千位显示为 36k |
CH64.3% | 整个会话已记录输入的缓存命中占比 |
$0.055 | 按模型分别估价后的合计,显示时保留三位小数 |
本例没有单独记录缓存写入量,所以不显示 W;有正数的写入统计时,状态栏会用 W 另列。
状态栏从会话累计用量计算 CH,分母包括普通输入、缓存读取和缓存写入,输出量不参与。计算使用原始 Token 数量,而不是状态栏上缩略后的数字,也不是对各次调用的命中率取算术平均。
本例沿用第 4.4 节的会话合计,未单独报告的缓存写入量按零参与计算:
CH = 累计缓存读取 ÷(累计普通输入 + 累计缓存读取 + 累计缓存写入)× 100%
= 35,700 ÷(19,800 + 35,700 + 0)× 100%
≈ 64.3%
CH 反映整个会话已记录输入的缓存命中占比,包含主 Agent、子代理和生成摘要的调用,与 ↑、R、W 的累计范围一致。 有已记录输入但缓存读取为零时,显示 CH0.0%;没有已记录输入时,不显示 CH。
金额后若带有 (sub),表示统计中涉及订阅计费;金额与实际扣款的区别见第 5.3 节。
子代理正在运行时,其本次完整用量尚未随 TaskRecord 交回,父会话的数字也不会随子代理每个流式片段实时增加。
6.2 未知用量与未知价格怎样提示?
界面会区分两种未知:
- 用量未知:部分调用的 Token 数量缺失,无法完整统计消耗。
- 价格未知:Token 数量已经记录,但无法确定对应单价。
Ink 的 usageMoneySegment 用不同符号提示这些状态:
| 标记 | 含义 |
|---|---|
~ | 还有已统计的 Token 未定价,显示金额只覆盖能定价的部分 |
! | 程序已发现用量缺报,显示金额不包含这部分未知消耗 |
$? | 当前没有可显示的已知金额,不能理解为费用为零 |
例如,~$0.055! 表示能定价的已知部分估算为 0.055 美元,同时还存在未定价的 Token 和程序已发现的用量缺报。
这些提示只反映程序已识别的问题。! 表示已发现用量缺报,但程序未必能发现没有保存下来的调用,因此没有 ! 也不代表实际用量一定完整。
7. 总结
Ink 以模型服务返回的用量报告为依据,汇总主 Agent、子代理及生成摘要的模型调用消耗,再按对应模型和 Token 类别的单价估算会话费用。这一估算取决于已保存的用量和查看时可用的费率,可能与实际账单不同。