跳到正文

Coding Agent 原理与实践

14:用量统计——Token 消耗与成本估算

作者:折腾客

约 8,119 字156 行代码

文章目录

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 项)

  1. 系统指令与工具定义8,000未命中
  2. 用户任务(登录排查)200未命中

本次输入用量

普通输入
8,200
缓存读取
0
完整输入
8,200
Token

本次模型回复

读取登录组件代码,检查错误提示的实现。

read({"path":"src/pages/Login.tsx"})

本次模型输出

100 Token

Agent 执行读文件工具

返回登录组件代码,共 1,700 Token。

结果将在第 2 次计入输入,不属于本次模型回复。

看第 2 次输入如何形成8,200 + 100 + 1,700 = 10,000

截至第 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 项)

  1. 系统指令与工具定义8,000命中
  2. 用户任务(登录排查)200命中
  3. 第 1 次模型输出:读取文件的工具调用100命中
  4. 读文件结果:登录组件代码1,700未命中

本次输入用量

普通输入
1,700
缓存读取
8,300
完整输入
10,000
Token

本次模型回复

错误被捕获后没有写入页面状态。
调用 edit,为错误处理添加页面提示。

本次模型输出

500 Token

Agent 执行修改工具

返回文件修改结果,共 300 Token。

结果将在第 3 次计入输入,不属于本次模型回复。

看第 3 次输入如何形成10,000 + 500 + 300 = 10,800

截至第 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 项)

  1. 系统指令与工具定义8,000命中
  2. 用户任务(登录排查)200命中
  3. 第 1 次模型输出:读取文件的工具调用100命中
  4. 读文件结果:登录组件代码1,700命中
  5. 第 2 次模型输出:分析与修改请求500命中
  6. 修改工具的执行结果300未命中

本次输入用量

普通输入
300
缓存读取
10,500
完整输入
10,800
Token

本次模型回复

运行登录相关测试,验证修改结果。

bash({"command":"npm test -- login"})

本次模型输出

100 Token

Agent 执行测试工具

返回测试结果,共 600 Token。

结果将在第 4 次计入输入,不属于本次模型回复。

看第 4 次输入如何形成10,800 + 100 + 600 = 11,500

截至第 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 项)

  1. 系统指令与工具定义8,000命中
  2. 用户任务(登录排查)200命中
  3. 第 1 次模型输出:读取文件的工具调用100命中
  4. 读文件结果:登录组件代码1,700命中
  5. 第 2 次模型输出:分析与修改请求500命中
  6. 修改工具的执行结果300命中
  7. 第 3 次模型输出:运行测试的工具调用100命中
  8. 测试工具的执行结果600未命中

本次输入用量

普通输入
600
缓存读取
10,900
完整输入
11,500
Token

本次模型回复

已补充密码错误时的页面提示,登录相关测试通过。

本次模型输出

300 Token

Agent 展示最终回答

任务结束,本次没有新的工具执行。

截至第 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 回复:parentassistant 消息的 message.usage
主会话生成摘要:summarizercompaction、branchSummary 记录的 usage
子代理本次运行:child工具结果中 TaskRecord.usage

继续登录排查。假设主 Agent 把“检查登录接口与错误提示处理”交给一个子代理。下面用一组教学报告,观察子代理检查代码、中途压缩自身上下文、再继续分析时,用量怎样被记下。

Ink 在每次 runSubagent 开始时,先创建一个用量账本。这里的账本就是保存本次运行累计用量的对象;每收到一份报告,就把数量加进去。假设这次依次收到三份报告,单位均为 Token:

发生的调用普通输入缓存读取输出
第一次分析:检查登录接口1,2006,000300
上下文压缩:生成压缩摘要2,0000200
第二次分析:确认错误提示的处理1,8000400

示例说明:这里直接假设中途满足了压缩条件,首次请求命中了已有的 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 回复

  1. 回复携带统一的 usage
  2. 保存 assistant 消息

汇入 parent

主会话生成摘要

  1. 生成压缩摘要或分支摘要,返回 usage
  2. 成功后保存摘要记录

汇入 summarizer

子代理本次运行

  1. 账本累计任务回复与上下文压缩的用量
  2. 随工具结果保存 TaskRecord

汇入 child

从主会话的全部条目计算 total

按来源累计数量,同时按产生用量的模型分组,用于费用估算。

从上向下阅读。子代理上下文压缩的用量已经包含在 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,80029,7001,000
主会话上下文压缩4,0000300
子代理任务回复3,0006,000700
子代理上下文压缩2,0000200
会话合计19,80035,7002,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 节的登录排查会话:

模型累计普通输入累计输出输入单价输出单价
A10,0001,00028
B20,0001,00014
C30,0001,000312

分别计算各模型的费用,再合计:

模型 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,80029,7001,300
B子代理任务回复、子代理生成压缩摘要的调用5,0006,000900

这仍是第 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 当前可用费率换算的,与模型服务提供商的实际账单可能存在差异。

  1. 用量或计费项目不完整。

    第 3 节提到的报告缺失、第 4.5 节提到的记录遗漏,都会影响金额;适配器也可能尚未覆盖全部计费项目。例如,OpenAI 的缓存文档列出了部分模型的缓存写入计费,而本文涉及的 Ink 两个 OpenAI 适配器尚未转换对应计数。

    此外,当前计价为每个模型使用一组固定费率,尚未覆盖按单次请求长度变化的阶梯计价。

  2. 部分用量无法定价。

    如果记录缺少完整模型身份,或者 Ink 查不到对应单价,就保留 Token 数量并标为未定价,金额只合计能查到价格的部分。

  3. 采用的是查看时可用的费率。

    Ink 在查看统计时查询价格,没有将每次调用时的单价随用量报告一起保存。因此,重新打开同一会话时,Token 数可以保持一致,估算金额却可能随模型目录中的费率变化。如果记录和费率都未变化,重新加载后的估算金额也相同。

  4. 订阅套餐的计费方式不同。

    目录中可能保留参考费率,但按 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 类别的单价估算会话费用。这一估算取决于已保存的用量和查看时可用的费率,可能与实际账单不同。