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实现延续协议循环

大多数模型调用只有一种结局:你发一个请求,拿到一个答案,结束。
但超长回答正在打破这种模式。
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提供恢复点,冻结请求提供匹配,缓冲提供诚实,计数器提供窗口的真相。长回答是一个循环,把检查点当作真相。
相关推荐

Agent Harness 已死?Cursor 早期工程师拆解 AI 编程代理真实架构
Cursor 早期工程师 Jediah 拆解 agent harness 的真实架构:为何「harness 已死」只对了一半,数据飞轮如何让 harness 自我淘汰,以及 Grokbot、软件工厂和原语设计如何打开 agent 的新前沿。

Python访问者模式与更优替代方案详解
深入解析Python访问者模式及四种替代方案:方法模式、模式匹配、字典映射与functools.singledispatch单分派。从节点与操作的双轴矩阵出发,帮你根据变化频率选择最合适的解耦设计。

357天深度体验:Omarchy为何让我告别发行版跳槽
连续使用 Omarchy 357 天的深度体验:59 秒安装、开箱即用的音频与 GPU 支持、Quattro 版本 QuickShell 重写带来的配置迁移阵痛,以及它为何值得 Arch 用户尝试。