dsh-stateful-responses
让 dsh 调用 GPT 模型时使用有状态的 Responses API:每一步只把新增的 input item 和上一轮的
previous_response_id 发给上游,而不是把整段会话重放一遍。
插件只改写发往上游的请求 body,并且只在上游明确证明它保存了上一轮(响应
store: true且带 response id)之后才发送previous_response_id。任何不确定的情况都会退回全量请求,因此最坏行为与 未安装插件完全一致。
- 目标运行时:DSH Harness / dsh 0.2.0-rc.2 及以上
- 运行环境:Node.js >= 22.19.0,无第三方依赖
- 上游要求:支持
store+previous_response_id才有收益;不支持时自动降级为全量 - 许可:MIT
目录
1. 它解决什么问题
dsh 的 agent loop 在每一步都重新构造完整对话——system instructions、工具定义、历史消息、工具结果—— 一并交给 provider。对于官方 OpenAI Responses API 这类服务端有状态的接口,这会造成两处重复:
- 传输:每一步都把整段历史重新序列化上传;
- 计费:历史 token 在每一步都被重新计入 input。
Responses API 的 continuation 机制正是为此设计的:上游保存上一次 response 的输入与输出,客户端只需要
发送新增 item 和 previous_response_id。
dsh 当前在这里有一个缺口:
llm-pi-ai的api: openai-responses适配负责协议转换,但没有 continuation 接线;- provider 注册是独占的(同一个 provider 不能再注册 adapter),插件无法从 provider 层接管;
- 请求扩展只能往请求体里加字段,而"不再重放历史"是会话循环的行为,不是单个字段。
因此本插件把改写点放在进程内最后一层:globalThis.fetch。
2. 设计目标
- 不修改 dsh 核心、不修改 provider 配置,也不重新实现 OpenAI 客户端;
- 只在确定安全时改写:宁可发全量,也不让上游收到一个拼接不上的状态;
- 自动降级:上游不支持时,行为与未安装插件完全一致(不丢上下文、不改变响应);
- 可解释:日志能回答"为什么改写 / 为什么回退 / 为什么最近没有收益";
- 有界:内存、链数量和并发语义都有上界,长时间运行不累积。
3. 架构
agent loop
└── llm-pi-ai (api: openai-responses)
└── OpenAI SDK
└── globalThis.fetch ← dsh-stateful-responses 在这里拦截
└── upstream POST /v1/responses
| 模块 | 职责 |
|---|---|
index.js |
插件入口:name + apply(ctx, config),安装/卸载拦截器 |
lib/interceptor.js |
核心:命中判定、请求改写、响应追踪、回退与降级 |
lib/chain.js |
有界 LRU,保存会话链(response id + item 哈希序列) |
lib/items.js |
item 规范化与哈希、"前缀扩展"判定 |
lib/sse.js |
SSE 终止事件(completed/failed/incomplete)收集 |
lib/config.js |
严格配置校验 |
cordis.patch.yml |
bundle patch:以 insert 行挂载插件 |
选择 fetch 层而不是 adapter 层的原因:
- pi-ai 的 OpenAI SDK 每次请求都使用全局
fetch,body 是 JSON 字符串——这是稳定的调用契约; - 不需要了解 pi-ai 内部实现,也不绑定其版本;
- 插件卸载时可以完整恢复原 fetch,不留残余包装;
- 不改任何 provider 配置,安装和回滚都只是增删一个 bundle。
4. 工作原理
4.1 命中条件
只有同时满足以下条件才处理,否则原样放行:
- 方法为
POST; - URL 以
/responses结尾,且命中配置的baseURLs; - body 是 JSON 字符串,含字符串
model与数组input; model命中配置的models(支持结尾*通配);- 调用方没有自带
previous_response_id。
4.2 会话链
链的键由请求的稳定要素组成:
chain key = url + model + prompt_cache_key + hash(tools) + hash(instructions)
不同模型、不同工具集、不同 system instructions 会自然分链;prompt_cache_key(dsh 会把会话 id 传给
provider)让不同会话不串链。链存放在有界 LRU 中(maxChains,默认 32),超出后淘汰最久未使用的链。
4.3 item 身份
每个 input / output item 先规范化再取 sha256。规范化会剔除上游可以重新生成的易变字段:
id、status、created_at、completed_at、annotations、logprobs、obfuscation。
这些字段只影响比较;真正发出去的永远是未经修改的 delta 原文,因此剔除不会破坏会话内容。
4.4 前缀扩展判定
只有当"本次请求的 item 序列是链中 item 序列的严格前缀扩展"时才允许续传:
- 前 N 项哈希逐一相同;
- 且本次至少多出 1 项。
否则说明会话分叉、被编辑或换了上下文,插件丢弃该链并按全量发送。
4.5 改写
命中时把 body 改成等价的增量请求:
{
"model": "gpt-6.1-sol",
"store": true,
"previous_response_id": "resp_...",
"input": [ "…only the new items…" ]
}
其余字段(tools、instructions、reasoning 参数、采样参数等)保持不变。
4.6 响应追踪
- 流式(SSE):对
response.body做tee(),一份原样交给 pi-ai,另一份交给内部收集器解析终止事件; - 非流式(JSON):通过
response.clone().json()读取,原响应不受影响。
无论哪种形式,插件都不会修改模型返回给 dsh 的字节内容。
4.7 链更新
只有响应满足 store === true 且带非空 id 时才更新链:
items = 链旧 items + 本次 delta + 本次响应 output 的 item 哈希
这条规则的直接推论是:链只在上游证明它保存了之后才存在。
4.8 失败与降级
| 情况 | 行为 |
|---|---|
完成响应 store !== true |
该 baseURL 标记为不支持,清空全部链,之后永远全量(每个 baseURL 只提示一次) |
响应 failed / cancelled |
只丢弃当前链,不标记不支持——失败不能证明上游不存储 |
续传请求非 2xx,且错误文本匹配 previous_response_id / previous response / not found |
丢弃链,用原始全量 body 重试一次 |
| 续传请求网络错误(非用户取消) | 丢弃链,用原始全量 body 重试一次 |
| 解析、规划、追踪等内部异常 | 放行原请求,不打断模型调用 |
5. 安全模型
不变量
- 没有
store: true的响应,永远不会生成previous_response_id; - 每次改写前都做 item 前缀校验,不匹配即全量;
tools/instructions/model/ URL /prompt_cache_key任一变化都不命中旧链;- 调用方自己设置的
previous_response_id一律放行,不参与链管理; - 对 pi-ai 可见的响应流不做任何修改(SSE 只读
tee分支); - 非 JSON 字符串 body(
Request对象、流式 body 等)一律放行; - 插件不读取、不缓存、不转发任何凭据:它只包装 fetch,鉴权头仍由 OpenAI SDK 生成并原样发送。
为什么不会丢上下文
- 能发
previous_response_id的前提是链存在;链存在的前提是上游返回过store: true且有 id; - 发送的
input是本次请求中"比链多出来的部分"的原文,不是插件重建的内容; - 链上的 items 由上游返回的 output 追加而成,与上游持有的状态同源;
- 上述任一环不成立,插件就发全量。
最坏情况
- 上游完全不支持状态:第一次响应后即被标记,后续全部走原路径——等价于未安装插件;
- 上游支持但状态过期或被清理:续传被拒,插件用全量重试一次并重建链,用户无感;
- 插件自身异常:请求原样放行。
隐私
插件会比较 prompt item,但不会持久化、不会写入日志、不会发送到第三方。日志只包含模型名、item 数量、 response id 等元数据,不包含 prompt 或补全内容。
6. 兼容性
- dsh 0.2.0-rc.2 及以上(依赖 bundle patch 机制与
ctx.effect生命周期); - Node.js >= 22.19.0(插件运行在 dsh host 进程内,不需要额外服务);
- 仅覆盖 OpenAI Responses 协议(
POST …/responses)。llm-deepseek使用的 Messages 协议没有等价的 continuation 字段,不在覆盖范围内; - 上游必须能接受它自己返回过的 output item(包括 reasoning item 的
encrypted_content)在下一次请求中 被带回;否则前缀校验失败,插件退回全量——安全但没有收益。
7. 安装
插件是一个标准 dsh bundle:包内的 cordis.patch.yml 通过 insert 增加一个插件行。
把仓库放到任意目录,例如
~/dev/dsh-stateful-responses。在目标 profile(
~/.dsh/profiles/<name>/package.json)中添加依赖与 bundle:{ "dependencies": { "dsh-stateful-responses": "link:/home/you/dev/dsh-stateful-responses" }, "dsh": { "profile": { "bundles": ["…", "dsh-stateful-responses"] } } }在该 profile 目录执行
pnpm install。重启 dsh(bundle 列表在启动时读取)。
插件默认不限定 baseURL 与模型(空数组 = 全部命中)。生产使用时建议把
baseURLs/models收窄到真正支持 Responses 状态的上游与模型,避免向其它端点发送store: true。
8. 配置
| 键 | 类型 | 默认 | 说明 |
|---|---|---|---|
enabled |
boolean | true |
关闭后不安装拦截器 |
forceStore |
boolean | true |
改写时强制 store: true |
baseURLs |
string[] | [] |
只拦截这些 URL 前缀下的 /responses;空 = 全部 |
models |
string[] | [] |
只拦截这些模型 id;支持结尾 * 通配;空 = 全部 |
maxChains |
positive integer | 32 |
最多保留多少条会话链 |
debug |
boolean | false |
输出每次改写 / 回退的决策日志 |
示例(cordis.patch.yml):
- id: stateful-responses
name: dsh-stateful-responses
config:
enabled: true
forceStore: true
baseURLs: ["https://api.openai.com/v1"]
models: ["gpt-*"]
maxChains: 32
debug: false
配置是严格的:未知键或类型错误会在激活时输出 warn,并且插件不会启用,而不是静默忽略。
9. 可观测性与排错
debug: true 时会输出:
rewriting request:本轮改写为增量(模型、item 数、delta 数、previous_response_id);chain updated:链已推进(response id、item 总数、本轮是全量还是增量);continuation rejected by upstream; retrying once with full history:续传被拒并已回退;response did not complete; chain dropped:失败响应导致链丢弃(上游仍被视为可用)。
每个 baseURL 只会出现一次的 info 级提示:
upstream does not retain responses (store is not true); using full history from now on
启用时输出 stateful Responses API interceptor installed;卸载时输出 restoring global fetch 以及本次
进程累计统计:requests / continuations / deltaItems / fullItems / fallbacks /
unsupportedBases / errors / chains。
没有命中任何条件时,插件不产生日志,也不改变请求。
10. 开发与测试
index.js 插件入口(name + apply)
lib/config.js 严格配置
lib/items.js item 规范化与哈希
lib/chain.js 有界 LRU
lib/interceptor.js fetch 拦截、改写、追踪、回退
lib/sse.js SSE 终止事件收集
cordis.patch.yml bundle patch(insert 行)
test/ node:test 用例(本地 mock 上游)
运行测试:
npm test # 等价于 node --test test/*.test.mjs
测试覆盖:
- 无状态上游:全量转发并被标记,之后不再改写;
- 有状态上游:连续两轮只发 delta,
previous_response_id正确串联; - 续传被拒:丢弃链并回退一次全量,重试成功后重新建链;
- 流式上游:SSE 原样透传,下一轮仍能续传;
- 非目标模型、调用方自带的
previous_response_id:完全不处理; - 会话分叉:全量重放并丢弃过期链;
prompt_cache_key隔离;失败响应不误标记;配置校验;item 规范化;LRU 淘汰;SSE 分帧。
无第三方依赖,使用 Node.js 内置 node:test。
11. 已知限制
- 收益完全取决于上游是否真的实现 Responses 存储;对无状态网关没有收益(但也没有副作用)。
- 只减少"发往上游的 body";dsh 本地 session log 与 loop 仍然构建完整历史。
- 同一链 key 的并发请求可能互相覆盖;下一次请求会因前缀不匹配而全量,随后自愈。
- 不做内容压缩或摘要,不改变模型选择、工具集或 system instructions。
- 不处理
Request对象或流式请求体(pi-ai 当前不使用这些形式)。
12. 许可
MIT © Zhexuan Yang
No comments yet. Be the first to write one.