[控场AI]
· 15 分钟阅读· 7,572 字

Gemini 4 Argon长解码:TypeScript实现延续协议循环

Gemini 4 Argon长解码:TypeScript实现延续协议循环

Gemini 4 Argon 延续协议把单次调用变成带检查点、重试与独立账务的状态机循环。

Gemini 4 Argon 推出的延续协议(Continuation Protocol)改变了超长回答的调用方式:单请求输出在 65,536 Token(非流式 32,768)处封顶,之后返回 `finish_reason=CONTINUATION` 与加密的 `continuation_token`,Token 内编码了原始提示词哈希与生成位置。文章用 TypeScript 给出最小实现,强调正确的循环必须冻结请求、缓冲流式文本、仅在检查点到达时提交内容、流断时从同一 Token 重试,并区分“上下文窗口口径”与“计费口径”的 Token 计数——前者是初始提示词加思考加文本,后者是每次请求输入的累加和,混淆会导致过早压缩。文章还通过四个演示场景验证了正确循环的行为,并指出常见错误(粘贴部分输出、直接流式写入答案)与真实场景的额外约束(重试成本、隐式缓存、超时与进度)。

Gemini 4 Argon长解码:TypeScript实现延续协议循环

Gemini 4 Argon延续协议

大多数模型调用只有一种结局:你发一个请求,拿到一个答案,结束。

但超长回答正在打破这种模式。

Google发布Gemini 4 Argon时,抛出了一句看似不起眼的话:"通过Gemini API实现业界领先的100万统一输入输出Token限制",同时邀请开发者阅读"新的延续协议"。这个协议,才是真正改变游戏规则的部分。

从单次请求到延续循环的范式转移

一个请求不必再承载整个答案。根据Google的开发者指南,单个请求在65,536个Token处封顶(非流式直接HTTP为32,768)。超过这个限制后,API会返回finish_reason=CONTINUATION,并附带一个加密的continuation_token,其中包含"原始提示词的哈希值和到该点为止的生成状态"。你把它发回去,模型就继续生成。

这个设计带来了四个原本免费的保障被打破。第一,请求必须保持完全一致;Token携带原始提示词的哈希,指南要求每次重发相同提示词。第二,文本可能在安全之前到达,流可能在段中间断开。第三,一个段可能全是思考过程,空文本不代表完成。第四,用量统计以奇怪的方式累加——每次延续都会重新发送提示词加上已解码的内容,所以累加的输入Token增长速度远快于上下文。

这些都不是模型Bug,而是循环Bug。模型提供检查点,你的循环决定什么算已提交。

关于 finish_reason=CONTINUATION 与加密 Token 的机制:这里的 finish_reason 是 Gemini API 响应中用于标记本次生成终止原因的标准字段,常见取值还包括 STOP(自然结束)、MAX_TOKENS(达到输出上限)、SAFETY(被安全策略截断)等。CONTINUATION 是一个新增语义——它并不代表失败,而是明确的“请继续”信号。配套的 continuation_token 并非服务端会话 ID,而是一段自包含(self-contained)的加密载荷,内部编码了原始提示词哈希与已生成位置 pos。这种设计的好处是无状态:服务端不必为每个未完成的解码维持会话,客户端只要妥善保管这个 Token,就能在任意时刻、任意实例上恢复生成。代价是提示词必须逐字节一致,否则哈希校验失败会返回 400。

关于非流式 HTTP 的 32,768 上限:流式(SSE / streamGenerateContent)与非流式(generateContent)在单请求输出上限上存在差异,前者可达 65,536,后者为 32,768。原因是流式传输允许服务端边生成边发送,内存压力更低;非流式需要把整段响应一次性组装返回,对缓冲有更严格的约束。这也解释了为什么文中假模型的 stream 方法返回的是一次性的检查点,而真实场景中你几乎总是希望用流式接口。

TypeScript最小实现:请求、检查点与用量

// tiny-continuation-loop: finish a long decode without lying about tokens.
import { createHash } from "node:crypto";

type Request = { contents: string; systemInstruction: string; tools: string[] };
type Finish = "CONTINUATION" | "STOP" | "MAX_TOKENS";
type Usage = { prompt: number; text: number; thoughts: number };
type Checkpoint = { text: string; finish: Finish; token?: string; usage: Usage };

const count = (s: string) => (s.trim() ? s.trim().split(/\s+/).length : 0);
const hash = (r: Request) => createHash("sha256").update(JSON.stringify(r)).digest("hex");
const pack = (o: object) => Buffer.from(JSON.stringify(o)).toString("base64url");
const unpack = (t: string) => JSON.parse(Buffer.from(t, "base64url").toString());

Request包含三个指南要求每次重发的要素:内容、系统指令和工具。Checkpoint是请求结束时返回的东西:文本、完成原因、可选的Token和用量统计。

简化版用普通base64包装一个哈希值,真实Token则是加密的。但核心思想——哈希——已经体现。

模拟分段解码的假模型

class FakeModel {
  requests = 0;
  private dropAt = -1;
  constructor(
    private perRequest: number,
    private thoughts: number,
    private answer: string[],
  ) {}

  dropNextStreamAfter(chunks: number): void {
    this.dropAt = chunks;
  }

  async *stream(req: Request, maxOutput: number, token?: string): AsyncGenerator<string, Checkpoint> {
    this.requests += 1;
    let pos = 0;
    if (token) {
      const t = unpack(token);
      if (t.h !== hash(req)) throw new Error("400: continuation token does not match this request");
      pos = t.pos;
    }
    const total = this.thoughts + this.answer.length;
    const end = Math.min(pos + this.perRequest, total, maxOutput);
    const thoughts = Math.max(0, Math.min(end, this.thoughts) - pos);
    const words = this.answer.slice(Math.max(0, pos - this.thoughts), Math.max(0, end - this.thoughts));
    for (let i = 0, n = 0; i < words.length; i += 10, n += 1) {
      if (n === this.dropAt) {
        this.dropAt = -1;
        throw new Error("stream dropped");
      }
      yield words.slice(i, i + 10).join(" ");
    }
    const prompt = count(req.contents) + count(req.systemInstruction) + pos;
    const finish: Finish = end >= total ? "STOP" : end >= maxOutput ? "MAX_TOKENS" : "CONTINUATION";
    const next = finish === "STOP" ? undefined : pack({ h: hash(req), pos: end });
    return { text: words.join(" "), finish, token: next, usage: { prompt, text: words.length, thoughts } };
  }
}

假模型从思考过程开始解码,然后是文本,每次最多perRequest个Token。三条规则直接借用文档:Token哈希不匹配返回400;提示词计数包含pos,因为每次延续都会重新预填充已解码内容;如果调用方maxOutput先耗尽,完成原因是MAX_TOKENS且仍返回Token。

核心循环:缓冲、提交与恢复

async function longDecode(model: FakeModel, req: Request, maxOutput: number, prev?: Run): Promise<Run> {
  const frozen: Request = structuredClone(req);
  const run: Run = prev ? { ...prev, parts: [...prev.parts], billed: { ...prev.billed }, status: "done" } : { ... };
  let token = prev?.token;
  let retries = 0;
  while (true) {
    const buffer: string[] = [];
    let cp: Checkpoint;
    try {
      const it = model.stream(frozen, maxOutput, token);
      let step = await it.next();
      while (!step.done) {
        buffer.push(step.value);
        step = await it.next();
      }
      cp = step.value;
    } catch (e) {
      const msg = (e as Error).message;
      if (msg === "stream dropped" && retries < 2) {
        retries += 1;
        continue;
      }
      return { ...run, status: "failed", error: msg };
    }
    retries = 0;
    run.segments += 1;
    run.parts.push(cp.text);
    run.billed.prompt += cp.usage.prompt;
    run.billed.text += cp.usage.text;
    run.billed.thoughts += cp.usage.thoughts;
    if (cp.finish === "CONTINUATION" && cp.token) {
      token = cp.token;
      continue;
    }
    if (cp.finish === "MAX_TOKENS" && cp.token) return { ...run, status: "raise-cap", token: cp.token };
    return { ...run, status: "done", token: undefined };
  }
}

关键序列是:冻结请求一次,每个段发送相同请求;将文本流式传输到buffer而非直接写入答案;只有检查点到达才提交缓冲并累加用量;CONTINUATION带Token时继续,即使文本为空;MAX_TOKENS带Token时停止并交回Token让调用方提高上限;流断时丢弃缓冲,从同一Token重试。

最后一条规则最有意思——前一个Token仍然有效,重试从最后提交的检查点开始,未提交的内容从未进入答案。prev参数让调用方可以用更高上限恢复,这与Vercel PR描述的做法一致。

计数上下文而非账单

const contextSize = (r: Run) => r.initialPrompt + r.billed.text + r.billed.thoughts;
const shouldCompact = (tokens: number, window: number) => tokens >= window * 0.8;

run.billed.prompt是每个请求输入的总和,接近你付费的量,但不是窗口中的内容。窗口中的内容是发送一次的提示词加上返回的思考和文本。

Google的指南正是让框架读取initial_prompt_token_count,并建议等到解码完成后再压缩。80%阈值只是示例。这个区别至关重要:如果框架错误地使用求和输入Token,会在上下文远未满时触发压缩,丢弃有价值的上下文。

关于 initial_prompt_token_count 与求和输入的差异:initial_prompt_token_count 只统计首次请求中提示词、系统指令和工具定义占用的 Token,它是“窗口占用”的起点。每次延续返回的 usage_metadata 中,prompt_token_count 却是“本次请求输入端的总量”,它包含原始提示词加上此前已解码的全部内容——所以它天然是累加增长的。把多段 prompt_token_count 相加得到的是“计费口径”,而 initial_prompt_token_count + 累计 text + 累计 thoughts 才是“窗口口径”。两者可以相差数倍,尤其在解码很长时。

关于隐式缓存折扣:Google 对长解码场景下重复跳转的输入前缀提供了隐式的缓存折扣(文档中提到约 95% 的减免),但这属于计费层面,不影响 Token 计数。也就是说,上下文窗口判断必须用窗口口径,成本估算才用计费口径加缓存折扣。混淆两者既会错误触发压缩,也会误判成本。

常见错误循环与四场景验证

人们最先写的两个循环都看似合理,但只在长输出时才暴露问题。ask-to-continue把部分输出粘贴到提示词并追加"Please continue",这改变了请求导致Token不再匹配;keep-partials将块直接流式写入答案,流断时从同一Token重试并再次追加该段,导致文本重复。

四个演示场景验证了正确循环的行为。场景一穿越仅思考段:第1段零文本,把空文本当完成的循环会立即退出,正确循环在四个段中获得完整答案;求和输入Token为892超过示例80%线,但实际上下文仅310,此时压缩是无谓的。场景二"please continue"改变请求后被拒绝,得到零文本。场景三流断:朴素循环为87个Token的答案产出107个Token,正确循环丢弃20个未提交Token,从同一Token重试,精确匹配。场景四上限耗尽:100上限时循环以raise-cap停止并保留Token,第二次调用上限200从中断处恢复。

关于为什么请求一致性如此关键:延续 Token 内部保存的哈希覆盖了 contents、systemInstruction 和 tools 三要素的完整序列化形态。任何一处字符变动——多一个句号、少一个空格、工具声明顺序调整——都会让哈希不匹配,服务端出于安全考虑直接拒绝。这不仅是 API 契约,也是防止“续写被引导到不同任务”的边界。因此正确实现必须用 structuredClone 冻结一次请求对象,并在整个循环中复用同一个引用,而不是每轮从原始输入重建。ask-to-continue 之所以失败,正是因为它把模型输出回填进了 contents,改变了提示词本身。

局限与更大的图景

假模型过于"礼貌"——真实段是数千Token,真实失败不会标注为"stream dropped",重试规则需要真实错误码。重试不是免费的——流断的段被解码两次,实际计费方式应查用量报告。假模型忽略缓存——Google的长解码文档描述了重复跳转输入95%的隐式缓存折扣,892是Token计数而非价格。时间维度缺失——指南列出每请求20分钟限制,真实循环需要超时、取消和可见进度。SDK可能已经处理这些——Google Gen AI SDK默认自动跟踪continuation_token,Vercel AI SDK现在也如此,只有需要自定义重试时才自己写循环。

更大的想法是:长解码正在悄悄改变"LLM调用"在Agent框架中的含义。 它曾经是一次往返,现在可能是一个带自己重试和账务的小型状态机。Google和Vercel在第一天就放入相同的计数警告是正确的直觉——让陷阱可见。

模型提供推理,Token提供恢复点,冻结请求提供匹配,缓冲提供诚实,计数器提供窗口的真相。长回答是一个循环,把检查点当作真相。

#Gemini 4 Argon#延续协议#continuation_token#TypeScript#长解码#Token计费#上下文窗口#AI SDK
分享:

相关推荐