OmniRoute context-relay 组合策略:跨账号配额轮换时的会话连续性接力机制
【免费下载链接】OmniRouteNever stop coding. Free MIT AI gateway: one endpoint, 352 providers (150+ free), 1200+ models Kimi, Claude, GPT, Gemini, GLM, DeepSeek, MiniMax. Works with Claude Code, Codex, Cursor, OpenCode, Cline & Copilot. Quota-aware auto-fallback, RTK+Caveman compression saves 15-95% tokens, MCP/A2A, Desktop/PWA. Built by 550+ contributors项目地址: https://gitcode.com/GitHub_Trending/om/OmniRoute
导读
context-relay是 OmniRoute 中的一种组合(Combo)路由策略,其核心目标是在同一提供商的多个账号之间按配额轮换时,保证会话上下文不中断。当活跃账号即将耗尽配额时,OmniRoute 会在后台生成一份紧凑的结构化交接摘要;认证层将同一会话的下一次请求路由到另一个账号后,这份摘要会以系统消息的形式注入新请求,让新账号无缝续接任务。读完本文你将掌握:该策略的触发阈值与运行时分层流程、交接数据(Handoff Payload)的持久化结构与注入机制、全部配置项及推荐使用模式,以及生成端与注入端在源码中的分工原理。
一、什么是 context-relay:优先路由之上的"接力层"
在 OmniRoute 的组合策略体系里,context-relay的定位非常明确:它并不取代优先级路由(priority routing)对模型的选择逻辑,而是在其之上叠加一层会话交接(handoff)处理。从当前运行时行为看,它表现为三层协作:
- 在活跃账号配额耗尽之前,OmniRoute 会生成一份紧凑的结构化摘要(不是完整对话回放,而是"延续所需的最小上下文");
- 认证(authentication)为同一会话选中了不同的账号后,OmniRoute 将这份摘要作为系统消息注入到下一次请求中;
- 交接摘要被成功消费(注入后的请求成功返回)后,它会从存储中删除。
这一机制在 open-sse/services/contextHandoff.ts 中实现,持久化层位于 src/lib/db/contextHandoffs.ts。
什么时候该用它
原文档给出了三个同时满足才推荐使用的条件:
- 组合预期会在同一提供商的多个账号之间轮换;
- 丢失短期的对话连续性会损害任务质量;
- 提供商暴露了足够的配额信息,能够提前预测账号即将触达上限。
这类场景最常见于长期运行的编码或研究型会话——它们往往比单个账号的配额窗口活得更久。例如一条组合挂载了同一提供商的 3 个账号,会话进行到深夜仍没结束,此时就需要接力机制保证换账号后模型依然"记得"之前做了哪些决策。
二、运行时分层流程:按配额使用率划分的四个阶段
原文档将 context-relay 的运行时行为刻意拆分为"生成"与"注入"两层(后文"架构说明"会详解拆分原因),并按配额使用率划分成四个阶段。
阶段一:配额使用 0%~84%——不生成任何交接
请求行为与普通优先级路由完全一致,不产生任何额外开销。这里对应源码中的预警阈值:
HANDOFF_WARNING_THRESHOLD = 0.85见 open-sse/services/contextHandoff.ts。只有当percentUsed >= handoffThreshold时maybeGenerateHandoff才会进入生成路径。
阶段二:配额使用 85%~94%——后台预生成交接摘要
如果当前活跃提供商在handoffProviders白名单内,OmniRoute 会在账号尚未完全耗尽时于后台生成结构化交接摘要。原文档强调的细节如下:
| 参数 | 值 | 说明 |
|---|---|---|
| 默认预警阈值 | 0.85 | 低于该值不生成摘要 |
| 生成硬停止线 | 0.95 | 达到或超过后不再调度新摘要请求 |
| 并发限制 | 每个sessionId + comboName仅允许 1 个在途生成 | 避免同一会话重复发起摘要请求 |
| 去重 | 若该会话/组合已存在活跃交接,则不重复生成 | 直接跳过 |
这些规则在源码中有完整对应。maybeGenerateHandoff(open-sse/services/contextHandoff.ts)依次检查:
if (relayConfig.handoffProviders.length === 0) return; // 白名单为空则禁用 if (options.percentUsed < relayConfig.handoffThreshold) return; // 未到预警线 if (options.percentUsed >= HANDOFF_EXHAUSTION_THRESHOLD) return; // 已到 0.95 硬停线 cleanupExpiredHandoffs(); if (hasActiveHandoff(sessionId, comboName)) return; // 已存在活跃交接 if (inflightHandoffGenerations.has(key)) return; // 已在途生成其中inflightHandoffGenerations是一个Set<string>,以`${sessionId}::${comboName}`为键(getInflightKey),生成结束后通过finally移除,从而保证"同一会话同一组合最多一个在途摘要请求"。生成动作通过setImmediate放到事件循环后台执行,不阻塞主请求链路。
阶段三:配额使用 ≥95%——不再生成新摘要
此时系统已处于或接近耗尽状态,运行时避免再调度一次摘要请求,以免把宝贵的剩余配额浪费在"整理交接信息"而不是"完成用户任务"上。源码中的硬停止线HANDOFF_EXHAUSTION_THRESHOLD = 0.95正是此边界(open-sse/services/contextHandoff.ts)。
阶段四:账号切换之后——仅在实际切换发生时注入
当同一会话的下一次请求被认证层解析到另一个已认证账号时,OmniRoute 将存储的交接以系统消息形式前置(prepend)到请求体中。注入只发生在真实账号切换被确认之后——这是整个机制安全性的关键:如果请求最终仍落在原账号上,注入反而会污染上下文。
三、Handoff Payload:交接数据的结构与持久化
存储位置与字段
交接数据持久化在context_handoffs表中,字段与类型由迁移脚本 src/lib/db/migrations/019_context_handoffs.sql 定义:
CREATE TABLE IF NOT EXISTS context_handoffs ( id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))), session_id TEXT NOT NULL, combo_name TEXT NOT NULL, from_account TEXT NOT NULL, summary TEXT NOT NULL, key_decisions TEXT NOT NULL DEFAULT '[]', task_progress TEXT NOT NULL DEFAULT '', active_entities TEXT NOT NULL DEFAULT '[]', message_count INTEGER NOT NULL DEFAULT 0, model TEXT NOT NULL DEFAULT '', warning_threshold_pct REAL NOT NULL DEFAULT 0.85, generated_at TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_context_handoffs_session ON context_handoffs(session_id, expires_at); CREATE INDEX IF NOT EXISTS idx_context_handoffs_expires ON context_handoffs(expires_at); CREATE UNIQUE INDEX IF NOT EXISTS idx_context_handoffs_session_combo ON context_handoffs(session_id, combo_name);原文档列出的 Payload 字段在代码层的HandoffPayload接口(src/lib/db/contextHandoffs.ts)中一一对应:
sessionId、comboName:交接的作用域键,联合唯一索引(session_id, combo_name)保证每个会话/组合只有一份交接;fromAccount:摘要来自哪个账号(连接),注入时用于判断"是否真的切换了账号";summary:延续所需的密集摘要;keyDecisions:已做的关键决策列表;taskProgress:已完成、待办与下一步;activeEntities:活跃上下文实体(如文件、功能、提供商);messageCount:参与摘要的消息数量;model:生成摘要所用的模型;warningThresholdPct:生成时的预警阈值(写入时来自relayConfig.handoffThreshold);generatedAt/expiresAt:生成时间与过期时间。
写入、读取与清理
- 写入:
upsertHandoff(src/lib/db/contextHandoffs.ts)使用INSERT ... ON CONFLICT(session_id, combo_name) DO UPDATE SET ...,天然幂等——同一会话/组合的新摘要会覆盖旧摘要; - 读取:
getHandoff只返回expires_at > now的未过期记录,按created_at DESC取最新一条; - 过期清理:
cleanupExpiredHandoffs删除所有expires_at <= now的记录,且带 30 分钟节流(CLEANUP_THROTTLE_MS),避免频繁全表扫描; - 消费后删除:
deleteHandoff在注入成功后被调用(详见第四节)。
摘要 JSON 结构
生成端通过提示词约束摘要模型返回如下结构的 JSON 对象(即原文档给出的结构):
{ "summary": "Dense summary of what matters for continuity", "keyDecisions": ["Decision 1", "Decision 2"], "taskProgress": "What is done, what is pending, and the next step", "activeEntities": ["fileA.ts", "feature X", "provider Y"] }源码中的提示词模板HANDOFF_PROMPT_TEMPLATE(open-sse/services/contextHandoff.ts)要求模型"只返回 JSON,不要 markdown、不要解释",并在解析侧做了完整防御:
MAX_SUMMARY_LENGTH = 2000、MAX_TASK_PROGRESS_LENGTH = 1200:截断超长字段;MAX_DECISIONS = 8、MAX_ENTITIES = 10:限制数组元素数量;parseHandoffJSON(open-sse/services/contextHandoff.ts)会先剥离 markdown 代码围栏(stripMarkdownCodeFence)与<omniModel>标签,再用firstBrace/lastBrace截取 JSON 候选段;解析失败(summary为空)则整体视为不可用;- 摘要请求体固定
temperature: 0.1、max_tokens: 800,并携带_omnirouteSkipContextRelay: true与_omnirouteInternalRequest: "context-handoff"标记,防止摘要请求自身再触发交接逻辑造成递归。
注入时的系统消息形态
在注入时刻,OmniRoute 将持久化 Payload 转换为<context_handoff>系统消息(buildHandoffSystemMessage,open-sse/services/contextHandoff.ts),其形态大致如下:
<context_handoff> <transfer_reason>Account quota transfer - continuing from previous session</transfer_reason> <session_summary>…</session_summary> <task_progress>…</task_progress> <key_decisions> - Decision 1 - Decision 2 </key_decisions> <active_context>fileA.ts, feature X, provider Y</active_context> <messages_processed>12</messages_processed> </context_handoff> You are continuing a conversation that was transferred from another account due to quota limits. The context above contains a concise summary of the prior work. Continue seamlessly from where the session left off.所有字段在拼接前都经过 XML 转义(escapeXml),避免对话内容中的<、&等字符破坏消息结构。注入时injectHandoffIntoBody(open-sse/services/contextHandoff.ts)会同时兼容两种请求形态:Chat Completions 形态(把系统消息前置到messages数组)与 Responses 形态(把交接内容拼进instructions字段),从而覆盖 Codex/OpenAI 风格的不同上游协议。
四、配置项:全局默认与组合级覆盖
context-relay支持以下配置字段:
| 配置字段 | 含义 | 默认值 | 说明 |
|---|---|---|---|
handoffThreshold | 触发摘要生成的预警阈值 | 0.85 | 取值范围需> 0且< 0.95(硬停线),否则回落默认值 |
handoffModel | 仅用于生成摘要的可选模型覆盖 | 空(使用当前请求模型) | 可用于选择更廉价/更快的模型做摘要 |
handoffProviders | 允许触发交接生成的白名单提供商 | ["codex"] | 不配置时默认只有 codex;配置空数组则禁用该策略 |
maxMessagesForSummary | 参与摘要的最大消息数 | 30 | 源码限制在 5~100 之间 |
relayMode | 交接模式 | standard | schema-locked时摘要采样不携带 system 消息且长度约束更严格 |
全局默认值可以在Settings(设置)页面配置,组合级专属值可以在Combos(组合)页面覆盖它们——这正是resolveUniversalHandoffConfig中"组合配置优先、全局配置兜底"的解析逻辑(open-sse/services/contextHandoff.ts):布尔、字符串、数字、字符串数组均按"先查 combo 再查 global 最后回落默认值"的优先级合并。
配置解析的具体规则在resolveContextRelayConfig(open-sse/services/contextHandoff.ts)中实现,几个容易踩坑的细节:
handoffThreshold若非法(非数字、≤0 或 ≥0.95),静默回落HANDOFF_WARNING_THRESHOLD(0.85);handoffProviders未显式配置时默认["codex"]——这与"当前实现以 codex 配额轮换为中心"的限制相呼应;- 提供商字符串会统一
trim().toLowerCase()后过滤空值,避免大小写不一致导致白名单失效。
摘要生成的"取材"策略
selectMessagesForSummary(open-sse/services/contextHandoff.ts)决定哪些历史消息进入摘要提示词:
- 默认模式(
standard)保留 system/developer 消息 + 最近maxMessagesForSummary条非系统消息; schema-locked模式丢弃 system 消息,只取最近消息(适用于不允许自定义 system 提示词的协议);- 无论哪种模式,最终历史文本都受
MAX_HISTORY_TOKENS_FOR_SUMMARY = 8000token 上限约束,超限时从旧消息开始裁剪,确保摘要请求本身不产生过大的上下文开销。
五、架构说明:为什么没有一个独立的 handler
原文档特别澄清:当前实现并没有一个独立的handleContextRelayCombo处理器,而是刻意把职责拆成两半:
- open-sse/services/combo.ts:决定"某次成功回合是否应该生成交接";
- src/sse/handlers/chat.ts:只在认证解析出本次请求实际使用的账号后,才注入交接。
这种拆分是有意的:组合循环(combo loop)本身无法可靠判断"请求是否停留在同一账号"——账号的选择发生在认证(auth)内部,组合层根本看不到。因此"生成"放在组合层(它掌握配额信息),"注入"放在认证之后(它掌握真实的账号切换结果)。
生成端:combo.ts 的调用链
在 open-sse/services/combo.ts 附近,context-relay组合的成功回合会通过fetchCodexQuota(connectionId)获取配额信息,然后调用:
maybeGenerateHandoff({ sessionId: relayOptions.sessionId, comboName: combo.name, connectionId, percentUsed: quotaInfo.percentUsed, messages: handoffSourceMessages, model: modelStr, expiresAt: resetCandidates[0] || null, config: relayConfig, handleSingleModel: handleSingleModelWithTimeout, });注意expiresAt取自配额重置时间(quotaInfo.windows?.session?.resetAt、weekly?.resetAt、resetAt中最早的一个)——交接的有效期与配额窗口对齐:账号配额一旦重置,旧交接也随之过期。若配额接口不可用(catch(() => null)),则跳过本次生成,不影响主链路。
注入端:chat.ts 的切换检测与消费
在 src/sse/handlers/chat.ts,认证路由解析出credentials.connectionId之后:
if ( comboStrategy === "context-relay" && comboName && runtimeOptions.sessionId && body?._omnirouteSkipContextRelay !== true ) { const handoff = getHandoff(runtimeOptions.sessionId, comboName); if (handoff && handoff.fromAccount !== credentials.connectionId) { requestBody = injectHandoffIntoBody(requestBody, handoff); injectedHandoff = handoff; // log.info("CONTEXT_RELAY", `Injecting handoff for session ...`) } }核心判据是handoff.fromAccount !== credentials.connectionId——只有交接来源账号与本次实际路由到的账号不同才注入;同一账号继续处理时,交接保持沉睡、不注入。而消费侧(src/sse/handlers/chat.ts)在请求成功返回后立即:
if (injectedHandoff && runtimeOptions.sessionId && comboName) { deleteHandoff(runtimeOptions.sessionId, comboName); }即"一次成功消费、随即删除"——交接是一次性的,避免被同一会话后续请求重复注入。
六、限制与边界(Limitations)
原文档明确列出以下限制,均可在源码中找到对应佐证:
- 运行时支持目前以
codex配额轮换为中心:默认handoffProviders就是["codex"],配额获取走fetchCodexQuota(open-sse/services/combo.ts); handoffProviders虽然已建模为可配置面,但真实的交接生成仍依赖各提供商各自的配额管道(quota plumbing)——非 codex 提供商即使加入白名单,也可能因为没有配额读取能力而无法触发;- 摘要是紧凑、基于近期历史的,不是完整对话回放(transcript replay)机制——
selectMessagesForSummary有 8000 token 上限且默认只取最近 30 条消息; - 交接以
sessionId + comboName为作用域并自动过期——expiresAt通常对齐配额重置时间,最坏情况也有 5 小时默认 TTL(DEFAULT_TTL_MS = 5 * 60 * 60 * 1000); - 如果会话没有切换账号,已存储的交接不会被注入——这是注入端
fromAccount !== connectionId判据的直接推论。
此外,open-sse/services/contextHandoff.ts 还为交接失败设计了退避机制:若摘要模型返回内容无法解析(unparseable),会对该(session, combo)施加指数退避冷却(初始 5 分钟、上限 1 小时),避免在模型高频切换的组合上反复浪费上游摘要调用。
七、推荐使用模式(Recommended Usage Pattern)
结合原文档与源码,落地context-relay的推荐做法如下:
- 使用同一提供商的多个账号——这是策略生效的前提,单账号场景下交接永远不会被注入;
- 在整个会话中保持稳定的
sessionId——交接的存储、去重、注入都以sessionId + comboName为键,sessionId 变化会导致交接对不上号; - 把
handoffThreshold设置得足够早——默认 0.85 意味着要留出 15% 的配额余量给后台摘要请求;如果摘要模型较慢或历史较长,可适当调低阈值(但必须 < 0.95); - 把该特性视为"连续性辅助"而非"持久记忆的替代品"——紧凑摘要无法承载完整记忆,关键信息仍应依赖 OmniRoute 的持久记忆/上下文管理能力(如 docs/frameworks/MEMORY.md 描述的记忆体系);
- 为摘要指定更经济的模型——通过
handoffModel用一个廉价模型承担摘要生成,避免占用主力模型配额。
八、测试佐证与延伸阅读
仓库为context-relay提供了完整的测试覆盖,可直接用于验证本文描述的行为:
- tests/unit/combo-context-relay.test.ts:组合层生成决策的单元测试;
- tests/unit/chat-context-relay.test.ts:chat 处理器注入逻辑的单元测试;
- tests/integration/combo-matrix/context-relay-handoff.test.ts:交接生成与注入的集成测试;
- tests/integration/combo-matrix/context-relay-codex.test.ts:codex 配额轮换场景的端到端验证。
核心源码入口按阅读顺序建议为:
- src/lib/db/migrations/019_context_handoffs.sql——先看表结构与索引,理解持久化边界;
- src/lib/db/contextHandoffs.ts——再看 CRUD 与清理逻辑;
- open-sse/services/contextHandoff.ts——重点研读生成决策(
maybeGenerateHandoff)、摘要解析(parseHandoffJSON)与注入(injectHandoffIntoBody); - open-sse/services/combo.ts——确认生成端的配额读取调用;
- src/sse/handlers/chat.ts——确认注入端"真实切换才注入、成功后即删除"的完整闭环。
原文档英文版本可通过 docs/i18n/cs/docs/features/context-relay.md 顶部的语言切换链接访问对应翻译,本文内容即以此文档为骨架、结合上述源码与迁移脚本整理而成。
【免费下载链接】OmniRouteNever stop coding. Free MIT AI gateway: one endpoint, 352 providers (150+ free), 1200+ models Kimi, Claude, GPT, Gemini, GLM, DeepSeek, MiniMax. Works with Claude Code, Codex, Cursor, OpenCode, Cline & Copilot. Quota-aware auto-fallback, RTK+Caveman compression saves 15-95% tokens, MCP/A2A, Desktop/PWA. Built by 550+ contributors项目地址: https://gitcode.com/GitHub_Trending/om/OmniRoute
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考