我前面一直在想一个问题:同一个 Harness 可以接很多模型,但每个模型的上下文上限、Token 计算方式、输出能力都可能不一样,它到底怎么保证请求不会超限?
我的理解是,不能只在配置里换一个模型名称。Harness 还需要同时解决三件事:
- 知道当前模型能接收多少内容。
- 计算这次准备发送多少 Token,并给输出和推理预留空间。
- 管理模型配置、会话上下文和 Prompt Cache,减少重复获取与重复计算。
整条链路可以先简化成这样:
text
用户任务 + 历史消息 + 记忆 + Skill + 工具定义和结果
↓
Harness 上下文管理
选择、裁剪、摘要、组装
↓
多模型适配层
模型上限、Tokenizer、输出预算、压缩阈值
↓
模型
1. 上下文到底是什么
上下文不是客户端里保存的全部数据,而是这一次真正交给模型的内容。
常见内容包括:
- 系统和开发者指令。
- 当前用户问题。
- 选中的历史对话。
- 从长期记忆或知识库中检索出的内容。
- 当前任务需要的 Skill 说明。
- 提供给模型的工具名称、描述和参数结构。
- 前面几轮的工具调用结果。
- 图片、文件等输入经过模型接口处理后的内容。
客户端可以保存一百轮历史,但 Harness 只选择其中二十轮发送,那么本次模型上下文就是这二十轮和其他输入的组合,不是全部一百轮。
text
会话存储 本次模型上下文
├─ 完整历史 ───────┐ ├─ 系统指令
├─ 长期记忆 ───────┼─ Harness ──→├─ 最近对话
├─ 工具结果 ───────┤ 选择组装 ├─ 相关记忆
└─ 附件与资料 ─────┘ └─ 工具定义和结果
所以,模型可以是无状态的,但会话仍然可以连续。连续性由客户端、Harness 或模型厂商的会话服务保存;下一次调用时,再通过完整消息、会话标识或前一响应标识把相关内容接上。
2. 不同模型需要哪些适配信息
Harness 至少需要维护下面这些模型元数据:
| 配置 | 用途 |
|---|---|
model_id |
实际调用的模型名称 |
context_window |
输入和生成内容可使用的总窗口 |
max_output_tokens |
单次允许生成的最大 Token |
tokenizer |
估算或精确计算输入 Token |
reasoning_support |
是否支持推理及可用的推理档位 |
tool_support |
是否支持 Function、MCP、并行工具等能力 |
modalities |
支持文本、图片、音频中的哪些输入 |
compact_threshold |
到什么位置开始主动压缩 |
safety_margin |
为计算误差和运行开销保留的缓冲区 |
这些配置可以写在本地,也可以由服务端动态下发。
yaml
model_id: example-reasoning-model
context_window: 200000
max_output_tokens: 32000
tokenizer: example_tokenizer
compact_threshold: 0.80
safety_margin_tokens: 8000
supports_tools: true
supports_reasoning: true
这只是通用示意,不对应某个真实模型。
关键点是:切换模型,不只是换 model_id,还要一起切换这套计算和能力配置。
3. Harness 怎么计算 Token
在调用模型前,可以先做一个预算:
text
可用输入预算
= 模型上下文上限
- 预留输出 Token
- 预留推理和安全缓冲
例如某个模型的上下文窗口是 200K,Harness 准备给输出和推理保留 32K,再留下 8K 安全空间,那么本次输入最好控制在 160K 以内。
text
200K 总窗口
├─ 160K:输入内容
├─ 32K:输出和推理预算
└─ 8K:安全缓冲
这里的数字不是统一标准。普通问答可以少留一些输出空间,代码生成、长报告或高强度推理则可能需要多留一些。
Token 数从哪里来
通常有三种方式:
- 使用对应模型或模型族的 Tokenizer 在本地计算。
- 使用模型厂商提供的计数接口或 SDK。
- 没有精确能力时,按字符数、消息结构和图片规格进行保守估算。
调用前得到的是预算值,调用后还要读取模型响应中的实际用量。实际用量一般会区分输入、缓存输入、输出和推理等字段,但各厂商返回结构不完全一样。
工具定义、消息角色、图片和结构化输入也会占用 Token,不能只计算用户输入的文字。
4. 什么时候压缩上下文
Harness 不需要等请求已经超过模型上限才处理。更稳妥的做法是提前设置有效上限:
text
预计输入 + 输出预留 + 安全缓冲 >= 有效上下文上限
↓
触发压缩
压缩通常不是简单删除最早的消息,可以分成几步:
- 删除重复日志、重复工具输出和已经失效的中间过程。
- 把较早的多轮对话总结成结构化摘要。
- 保留当前目标、约束、重要决定、关键证据和未完成事项。
- 最近几轮对话尽量保留原文。
- 需要时再从长期记忆或知识库检索原始资料。
text
压缩前
历史原文 + 多次工具输出 + 当前任务
↓
压缩后
历史摘要 + 关键证据 + 最近对话 + 当前任务
摘要也可能丢信息,所以完整审计记录不应该只保存在摘要里。会话原始记录、任务状态和发送给模型的上下文,最好分开保存。
部分模型接口支持自动截断。以 OpenAI Responses API 为例,truncation=auto 可以在超出窗口时从会话开头丢弃内容;关闭自动截断时,请求可能直接失败。对工程型 Agent 来说,Harness 主动选择和压缩通常更可控,因为它知道哪些目标、授权和证据不能丢。OpenAI Responses API
5. 模型切换后为什么要重新计算
假设一段上下文在模型 A 中是 80K Token,切换到模型 B 后,不能直接认为仍然是 80K。
原因包括:
- 两个模型可能使用不同的 Tokenizer。
- 上下文窗口和最大输出不同。
- 对图片、工具定义和结构化消息的计算方式可能不同。
- 推理模型需要不同的输出和推理预算。
- 新模型不一定支持原有工具或消息格式。
因此,模型切换流程应该是:
text
选择新模型
↓
加载新模型元数据
↓
使用对应 Tokenizer 重新计算
↓
重新分配输入、输出和安全预算
↓
必要时重新压缩
↓
发起模型调用
如果 Harness 只做模型路由,却不重新计算上下文,最常见的结果就是请求超限、上下文被意外截断,或者预留的输出空间不够。
6. 这里至少有三种"缓存"
讨论缓存时,最容易把不同机制混到一起。我目前把它分成三层。
6.1 模型配置缓存
这一层保存模型目录和适配信息,例如:
- 有哪些可用模型。
- 每个模型的上下文窗口。
- 输出上限和推理档位。
- 工具、图片等能力。
- Tokenizer 和压缩阈值。
它可以使用 TTL + ETag + 版本号:
text
Harness 需要模型配置
↓
本地缓存有效?
├─ 是 → 直接使用
└─ 否 → 携带 ETag 请求配置服务
├─ 未变化 → 续期缓存
├─ 有变化 → 更新本地缓存
└─ 请求失败 → 使用旧缓存降级
触发刷新可以是启动时、缓存过期时、每次任务前、后台轮询、服务端推送或手工刷新。不同 Harness 可以采用不同组合。
这里的"过期"通常表示需要重新校验,不等于立刻删除旧配置。保留一份最近成功的配置,可以在网络异常时继续提供降级能力。
6.2 会话与上下文缓存
这一层保存历史消息、摘要、工具结果和检索结果。严格来说,其中一部分是会话状态,不只是缓存。
Harness 可以保存完整历史,同时缓存已经生成的摘要。下一轮优先复用摘要;只有摘要失效或任务范围变化时,才重新整理。
但不能把会话 ID 当成模型天然记住了历史。会话 ID 只是索引,真正的历史仍由客户端、Harness 或服务端存储和重新关联。
6.3 模型厂商的 Prompt Cache
Prompt Cache 是模型服务端对重复提示前缀的复用。它主要降低重复输入的延迟和成本,不等于会话记忆,也不会替 Harness 决定本轮应该发送哪些历史。
要提高命中率,通常会把稳定内容放在前面,把每次变化的用户问题和动态数据放在后面。OpenAI 的模型接口也提供 Prompt Cache 相关参数和用量字段,具体支持范围随模型变化。OpenAI 模型指南
三层缓存可以这样区分:
| 层次 | 主要保存什么 | 主要目的 |
|---|---|---|
| 模型配置缓存 | 模型能力、上限、Tokenizer、版本 | 正确适配不同模型 |
| 会话与上下文缓存 | 历史、摘要、记忆、工具结果 | 保持任务连续,减少重复整理 |
| Prompt Cache | 重复的模型输入前缀 | 降低服务端重复计算、延迟和费用 |
7. 一个完整的运行过程
假设用户继续追问一个已经进行多轮的代码问题,Harness 可以这样处理:
text
1. 收到用户新问题
↓
2. 根据任务和规则选择模型
↓
3. 读取模型配置缓存
过期则向配置服务校验
↓
4. 读取会话历史、摘要和相关记忆
↓
5. 使用该模型的 Tokenizer 计算输入
↓
6. 超过有效阈值则裁剪或压缩
↓
7. 组装指令、消息、Skill 和工具定义
↓
8. 调用模型,模型可能回答或请求工具
↓
9. 工具结果回到 Harness,再进入下一轮模型调用
↓
10. 保存实际用量、会话状态和新的摘要
这里可能调用模型一次,也可能调用很多次。Token 计算、缓存读取和权限校验不一定需要调用模型;生成摘要通常需要模型,但也可以使用规则或程序处理一部分内容。
8. Codex 的本机观察案例
在我本机的 Codex Desktop 0.151.0 中,模型目录缓存包含:
fetched_at:上次获取或续期时间。etag:判断服务端模型目录是否变化。client_version:缓存对应的客户端版本。- 模型上下文、输出、推理档位等元数据。
本机客户端中的模型缓存 TTL 是 300 秒,也就是大约 5 分钟。超过时间后并不是删除缓存,而是重新校验;服务端内容没变化时,只需要给缓存续期。
这个值是对当前本机版本的观察,不是 Harness 标准,也不是 OpenAI 对所有 Codex 版本的固定承诺。其他 Harness 完全可以使用不同 TTL,甚至不用轮询,改成服务端推送。
OpenAI 官方提供独立的模型目录,用来描述不同模型的上下文、输出和能力;具体 Harness 如何缓存这些信息,仍然属于客户端实现。OpenAI 模型目录
9. 实现时我会重点守住哪些边界
如果自己做一套 Harness,我觉得下面几条比较重要:
- 模型配置要带版本,不能只按模型名称硬编码。
- 模型切换后必须重新计算 Token 和输出预算。
- 调用前做估算,调用后记录模型返回的实际用量。
- 压缩不能丢失目标、约束、授权、决定和关键证据。
- Prompt Cache、会话历史和长期记忆必须分开理解。
- 配置服务不可用时,可以使用最近成功的缓存降级。
- 缓存刷新和模型调用分开,刷新配置通常不需要调用模型。
- 对每个模型做真实测试,不能只相信配置中的理论上限。
10. 最后总结
我的理解可以压缩成一句话:
模型决定怎么回答,Harness 决定给模型什么;要接入不同模型,Harness 就必须知道各自的上限和计算方式,在调用前完成 Token 预算、上下文压缩与缓存管理。
最核心的关系是:
text
模型配置缓存 → 告诉 Harness 当前模型能做什么
会话上下文 → 告诉模型这次任务已经发生了什么
Token 计算 → 判断这些内容是否放得下
上下文压缩 → 在放不下时保留真正重要的信息
Prompt Cache → 减少重复前缀的服务端计算
它们组合起来,才是一套能稳定适配多模型的上下文管理机制。