Replies: 2 comments 1 reply
|
Your A/D analysis is right about the mechanism, and the single-character localization is the best evidence this family has. One correction that matters: section C says a plugin cannot do the automatic recovery because the moderation signal is crushed into the generic The signal is reachable at
|
| trigger location | rewritten by default? |
|---|---|
| user text, and text nested in tool results | yes |
| assistant text | opt in via roles |
inside tool-call arguments |
never — raw JSON, a character transform would corrupt the call |
inside a tool schema (options.tools) |
never — enum/const/pattern strings would change meaning |
What it does not do — including one of your asks
- It removes nothing from the log. The poison stays; what stops is its transmission. Your fork-based recovery is still the only route to a clean log, and that is why B and D remain worth doing upstream.
- It does not tell the model. Your A3 — "把这次撤销告知模型,让它不要再去抓同一个来源" — is not covered: the model reads a spaced request, not an explanation, so nothing warns it off re-fetching the same page. That part of your ask stays open.
- If every rung is refused, the trigger is outside what a character transform can break; that outcome is itself the localization answer.
- Fidelity and cost: a firing rung invalidates the provider's prefix cache from the first rewritten message onward and roughly doubles the characters it touches. Paid only by a session whose alternative is failing every turn.
The plugin
@argszero/cordis-plugin-content-exists-risk · npm · repo
- insert:
- id: content-exists-risk
name: '@argszero/cordis-plugin-content-exists-risk'47 tests against a real @deepseek-ai/cordis context and a real @deepseek-ai/dsh-llm runtime, with a stub provider that refuses the way moderation does (terminal error finish, no content). The unmounted control arm fails on the same request — that control is what makes the rest evidence rather than assertion — and nine mutations of the implementation each turned the suite red.
It is additive to your A/B/D, not a substitute: it needs no new error code and no surface-replacement API, so it can land today, and if the typed code lands it gets simpler rather than obsolete. Your D is a real gap — the export path gives you a readable log and no supported way to write one back, and rebuilding frames and seq continuity by hand while avoiding the single-writer claim is not a reasonable thing to ask of a user.
For the record, you are the sixth report in this family in two weeks: #5445, #6476, #6490, #6495, #7181, and now this one. The pattern in your report — the fetch itself succeeds and the next turn is the one that dies, and a fork of the poisoned point is clean — is the same in all of them, and your instrumentation is the first to reduce the trigger to a single character.
|
补充一条机制层面的增量(不是"我也遇到了"):这一类触发物比"不可枚举"要好一些 —— 至少其中一族是可枚举的,而且最小复现只要 2 个码位,不需要一个 15k 字符的网页。 1. 最小复现:请求体 2 个码位一个特定的地区缩写字形(Unicode 区域指示符对,U+1F1F9 U+1F1FC,对应拉丁缩写 2. 单变量对照矩阵(同一端点、同一时间窗,只改内容这一个变量)
⇒ 判定粒度是具体字形簇:同义文字放行、孤立码位放行、普通 emoji 放行、非国家码组合全放行。既不是"讨论某地区就拦",也不是"所有旗帜/所有 emoji 都拦"。 确定性:同一字形连打 3 次全 400;间隔 2s / 15s / 60s 全 400;三把不同 key 全 400;官方端点与自建网关同分钟交替打结果完全一致。 3. 对 #7310 的一处修正#7310 的结论是"这类符号文本层不可枚举、随机 → 内容层面的预先规避在原理上不可行"。前半句对,后半句可以放宽:至少"区域指示符对"这一族是可枚举的(26×26 组合里,非国家码的 26 个样本 26/26 放行;真实国家码里存在命中项)。这意味着在请求边界做字符类归一化(把区域指示符对替换成文字标签,例如 4. 报告本身是毒源(新增数据点)把 #5445 的正文(含附件样本)作为请求体单发 → 稳定 400。也就是说任何 agent 都无法读取那条帖子来参与排查;我这边是在本地做了"隔离被拒节点"才没有把会话搭进去。这给"至少要在错误里给出被判定内容的位置"增加了一条独立理由。 另:用客户端侧的"审核接口即探针 + 二分"脚本跑在这些含样本的帖子上,26 次 2 字符级探测就把触发窗口收敛到 4 个字符(与你 #6476 测得的最小单元一致)。⇒ 服务端若在响应里给出 message index / 码位区间,调用方可以直接定位,成本极低。 5. 独立第三方复现(不同客户端、不同网关、同一模型)
6. 没验的
已在 |
Uh oh!
There was an error while loading. Please reload this page.
[Bug] 一次内容审核 400 会永久废掉整个会话 —— 需要「撤销被拒内容并继续」的回滚能力
摘要
一次内容审核拒绝(
Content Exists Risk,HTTP 400)会让整个会话永久失效:之后每一条消息都被同一条 400 拒掉,无法继续、无法恢复、无法自救。一个工具结果(15151 字符)轻松炸掉了 61 轮 / 3249 个事件的会话。这件事里没有一个环节是我自愿的,也没有一个环节是我能控制的。 我提交、粘贴、上传的违规内容为零:我只是在一个正常会话里问了一句「有没有什么用于 recap 的插件」。之后的每一步都不是我的选择——
web_searchweb_fetch我在这条链路上唯一做的事就是提问。而最终的代价由我单方面承担,且不可逆。(我在让模型给这篇讨论添加相关链接时,让模型读讨论区的时又炸了几个session,太痛苦了)
并且我后续根据轨迹定位到了触发物:是一个 GEO 投毒页面里的一个政治敏感地区缩写 emoji。(能很方便地看轨迹这点还是要夸一下 DSH)不是文字、不是违规词、不是广告、不是提示注入 —— 是页面中部一个孤立的单字符图形标记,正常阅读时根本看不见(定位过程见下)。我这边因此不存在"注意一点就能避开"的解法:触发物不可枚举,任何一次联网检索都可能把同类字符带进上下文。
我期望的维护者或开发者行为是下面两者之一:
现状是两者都没有。而唯一还能用的自救手段(fork)本身也是坏的,见「相关讨论」。
触发方式
机制:拒绝发生在「下一轮」,不在抓取那一刻
三次实验,结论稳定:
web_fetch抓取成功(工具结果 15151 字符)→ 19:09:35 本轮正常完成,没有被拒 → 19:13:00 我发出下一条消息,立刻 400。也就是说:抓取本身成功,被拒内容只是被放进了会话表面;下一轮把整段表面重发,审核在整段请求体上重新判定,必然命中同一条 400。审核针对的是整段上下文而非那一页内容,所以换个上下文就不触发。
这同时证明回滚可行:fork 出来的会话一切正常,说明被拒内容一旦离开表面,会话就能继续。事实上用户现在唯一能用的恢复手段,就是一次手工的、代价高昂的"回滚"。
影响
rpcId与源会话那条完全相同),我不得不补发说明或终止该轮才能拉回来。已单独报告,见「相关讨论」。触发物:页面里一个不可见为文字的字符
恕我无法提供这个网页,我不太想因为这个issue导致什么额外的问题。
被抓回来的内容是一个用模板程序化生成的插件清单页,靠堆砌术语密度让模型把它当成权威来源(典型的面向 AI 的内容投毒)。人类读者一眼能看出是垃圾,但它看起来完全像一份正常的技术清单。纯统计扫描(不读原文):
dsh-*形态词dsh,前 4 字符只有 3 种开头上面那轮扫描一个违规特征都没找到,所以我做了分块实验:把页面正文切成每块 2000 字符逐块送进上下文 —— 第 1 块直接 400,会话报废。再对第 1 块做逐字符分析(全程只输出坐标,不读原文),锁定到:
第 136–142 行结构完全一致 —— 每行都是「一个空格 + 换行」的空行,只有第 141 行在空格后面挂了这个符号。它孤零零地嵌在页面中部,正常阅读时不可能被注意到。
炸掉一整个 61 轮会话的,就是这一个字符。 由此:
根因(阅读随附 bundle 得出)
1. 该判定被归类成通用「坏请求」,判定细节没进结构化错误。
@deepseek-ai/dsh-llm-deepseek/lib/index.js的httpErrorCode()(第 1526 行,400 分支在第 1537-1540 行)把所有非「上下文超限」的 400 一律返回INVALID_REQUEST。审核结论只活在人类可读的message里,下游没有任何东西能结构化地对它分支 —— 这是"无法自动回滚"的第一道缺口。2. 重试策略在设计上帮不上忙。
@deepseek-ai/dsh-llm-retry按 provider + code 决定重试;重试INVALID_REQUEST等于重发一份逐字节相同的表面,必然复现同一条拒绝。加进retryableCodes只会把"锁死"变成"重试风暴"。3. 缺少「定位并移除被拒节点」的动作。 同一个文件的流式错误路径(第 1794-1801 行)对"文件 id 被拒"会替换后重试一次,对"归一化图片被拒"会给出诊断 —— 内容审核拒绝是同一种形状的问题,却没有对应分支。
4. 需要的机件已经存在。
@deepseek-ai/dsh-compaction-tool-result-pruner已经在改写模型可见表面里的工具结果;@deepseek-ai/dsh-session暴露了 surface 替换能力;实测也证明把被拒内容移出表面后会话就能继续。所以这不是"需要新架构",而是在既有接缝上补一个分支,再补一个用户入口。
建议修法
A. 自动回滚(主要诉求)
httpErrorCode()里识别内容审核判定,给它独立的 code(例如CONTENT_REJECTED),让下游能分支。[内容已移除:被服务端审核拒绝]),然后只重试一次,绝不循环。B. 用户侧回滚入口(底线诉求)
即使自动修复一时做不了,只要能让我删掉 / 隔离最后一个外部内容节点,就能把"死会话"变成"可恢复会话"。它把决定权交还给用户,而不是让一次搜索的代价不可逆。
C. 这为什么应该由 DSH 官方做,而不是留给插件
在官方补上"错误分类"和"表面替换接缝"之前,插件做不到自动回滚:插件在工具层很强,但拿不到"这次拒绝是内容审核"这个信号 —— 它被压成了通用
INVALID_REQUEST(见「根因」第 1 条)。所以插件只能停在最外面那层:在工具执行前拦掉已知的坏来源(例如按域名黑名单阻止web_fetch,社区已有dsh-file-shield这类做法)。那是有效的预防,但救不了已经中毒的会话,也无法泛化 —— 本次触发物是"某个域名上恰好有一个字符",下次可以是任何域名、任何字符。正确顺序是:官方先给出
CONTENT_REJECTED之类的可分支错误 + 一个受支持的表面修复动作,插件再在这个契约之上做扩展(比如自定义回滚策略、通知、审计)。把这件事整个推给插件,等于让插件去改会话的持久化数据和 fork 语义 —— 那是绕过而不是解决。D. 顺带:已有的导出能力缺一个"写"的半边
我想手动回滚时发现,会话历史是多帧 zstd 压缩二进制。DSH 其实已经提供了导出能力(
@deepseek-ai/dsh-session-log-export:命令/export、会话头部的 Session log 下载按钮、认证路由/api/session.export,导出dsh-session-<id>.zip,里面是纯文本session.jsonl)—— 但它只给了我"读",没给我"写"。我能把日志导成可读文本、能定位并删掉那条出问题的节点,却没有任何受支持的路径把它写回去:没有session repair之类的 CLI 命令,手工重建.jsonl.zstd时要自己保证帧头、校验和、seq 连续性,还要避开"一个会话一个活跃写者"的约束;社区手册(deepseek-harness-handbook)也明确写了不要在 harness 进程仍持有该会话时替换产物。结果就是:导出能力让我看清了病因,却仍然只能自己写工具去动那个文件 —— 这很蠢。 给一条官方支持的会话修复 / 回滚入口,是 A 和 B 之外最省事的一项。我这边可以做的
我不是 DSH 的开发者,只是碰到这个问题的用户。但如果方向被认可,我可以尝试把 A / B 实现出来:这些改动都落在既有接缝上,不涉及新架构。
具体我能定位到的位置:
httpErrorCode()里补一条审核分支,给出可分支的错误码;@deepseek-ai/dsh-compaction-tool-result-pruner与@deepseek-ai/dsh-session已有的表面改写能力,做"定位被拒节点 → 替换成占位标记 → 只重试一次";按官方贡献指南,目前不接受外部 PR,所以我会把这些以补丁 / 思路的形式发在 Discussions 里,或做成社区插件,直接提交 PR 就不必了。需要的话我可以先交一份最小原型或验证方案 —— 但先说清楚:我不保证效果,也不保证一次就能改对。
相关讨论
同题 / 同族(都是「一个特定 400 让该会话之后每一轮都失败」)
content or tool_calls must be set,毒死整会话同诉求(想要「撤销 / 回滚」这个动作本身)
fork 血缘(本 issue 说 fork 是唯一恢复手段,而 fork 本身也坏了)
外部手册(与我的经历互相印证)
.jsonl.zstd("a concatenation of checksummed Zstandard frames, not plain JSONL")。它作为规避是对的,但没有任何一条能让我继续原来那个会话 —— 那正是本 issue 要的。附 1:最小事故包
按社区手册的格式整理,便于直接比对:
附 2:我目前的临时规避
用 DSH 自带的原生桥
@deepseek-ai/dsh-hooks-claude-code挂一个PreToolUse钩子,在web_fetch/web_search执行前比对域名黑名单,命中就拒绝整次调用。它是预防性的,且只在"已经知道肇事域名"的前提下有效:救不了已中毒的会话,也无法泛化。这次触发物是一个政治敏感地区缩写 emoji —— 本次是"某个域名上恰好有一个这样的字符",下一次可能是另一个域名、另一个字符。真正需要的是 A / B 那样的回滚能力:一个能在事后把毒内容拿出去、并且能事前告诉用户"我抓回来的东西有问题"的机制。
All reactions