DeepSeek Harness 报 SELF_SIGNED_CERT_IN_CHAIN:杀毒软件换签证书链

故障排查发布于 2026-10-03作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSHSELF_SIGNED_CERT_IN_CHAINTLSNODE_USE_SYSTEM_CANODE_EXTRA_CA_CERTS卡巴斯基
DSH 调 DeepSeek API 报 SELF_SIGNED_CERT_IN_CHAIN,或只显示 transport failed 看不到原因:多半是卡巴斯基等安全软件/代理的 HTTPS 扫描重签了证书链,而 Node 不读系统信任库。本文给出报错分诊、keep-alive「时好时坏」机制与三种解法。

DSH 调模型突然开始报 SELF_SIGNED_CERT_IN_CHAIN,或界面只显示一行「DeepSeek Messages transport failed」——这两条报错的根因经常是同一个:本机某段链路(卡巴斯基等安全软件的「加密连接扫描」、或 Clash 这类做透明接管的代理)把 TLS 连接中间人换签了,而 Node 的 fetch 默认不读系统信任库**,于是握手失败。** 好消息是它完全可诊断、可修复,而且不必在「保留杀毒扫描」和「用 DSH」之间二选一。本文按「先分诊 → 再定位 → 三种解法」展开:先教你一眼分清报错落在 TLS 层还是传输层,再给出证书级证据与「时好时坏」的 keep-alive 机制,最后给三种解法——推荐单变量的 NODE_USE_SYSTEM_CA、最小改动的 NODE_EXTRA_CA_CERTS,以及留在安全软件侧加排除项。

先分诊:两条报错,落在两条不同的链

动手之前先看报错原文——SELF_SIGNED_CERT_IN_CHAIN 落在 TLS 握手层,transport failed 落在传输层,两者的排查路径完全不同。

报错一:SELF_SIGNED_CERT_IN_CHAIN(TLS 握手层)

最小复现命令(社区在 Windows 与 macOS 上都用它一步定位,#6338、#6420):

bash
node -e "fetch('https://api.deepseek.com').then(r => console.log('HTTP', r.status)).catch(e => { console.error(e, e.cause); process.exit(1) })"

命中时输出:

text
TypeError: fetch failed
[cause]: Error: self-signed certificate in certificate chain
code: 'SELF_SIGNED_CERT_IN_CHAIN'

关键判断:这个错误码不是 DSH 生成的。 全仓搜不到对 SELF_SIGNED 的任何处理或映射,它是 Node TLS 层的内置错误;DSH 只是把它包进 LlmError("DeepSeek API request to ... failed", 'TRANSPORT', { cause })(packages/llm/llm-deepseek/src/adapter.ts:657-663),原始 cause 链里就是 TLS 报的那句(#6338)。

⚠️ 一个容易把结论带偏的实验陷阱:如果你是在卸载安全软件之后才跑诊断命令、看到链是 TrustAsia → DigiCert 的正常链,那不能反推「安全软件不是中间人」——你只是测错了时刻。rejectUnauthorized: false 并不是「不校验」,它让连接在校验失败时继续建立,但校验照跑、结果仍记在 authorized / authorizationError 里。只有在扫描开启时跑,才能看到 issuer 变成 Kaspersky 且 authError 变成 SELF_SIGNED_CERT_IN_CHAIN(#6338)。

报错二:DeepSeek Messages transport failed(传输层)

这条串是 DSH 自己的分类标签,不是上游原文,所以它能告诉你的信息比看上去多(#6987)。

它只在 packages/llm/llm-deepseek/src/protocols/messages/adapter.ts:79 产生,条件是:请求生成器抛出的不是 LlmError(:76-79)。同一条生成器里,所有协议层/HTTP 层失败都有自己的文案与错误码,会原样抛出而不会落进这个分支:

触发条件报错文案 / 错误码位置
空闲看门狗触发DeepSeek Messages stream idle timeout / TIMEOUTadapter.ts:76
用户中断DeepSeek Messages request aborted / ABORTEDadapter.ts:77
HTTP 非 2xxAUTH / QUOTA / RATE_LIMIT / CONTEXT_WINDOW_EXCEEDED / INVALID_REQUEST / SERVER / HTTP_<status>.../messages/transport.ts:29-35
SSE 帧坏 JSON、或事件类型与 type 不符MALFORMED_RESPONSE.../messages/sse.ts:19、:23
流结束仍未见到 message_stopSTREAM_CLOSED.../messages/translate.ts:165
响应没有 bodyEMPTY_RESPONSEadapter.ts:147

所以这条报错的含义是:请求本身或响应体流在 SSE/协议层之下断了(连接被重置、链路/代理中断、TLS 或 HTTP2 层失败等)。它不是「网关少发终态事件」,不是限流,也不是鉴权。

为什么你看不到真正的原因

已重试模型请求(4/4) 说明它被判为可重试并已重试到上限——TRANSPORT 属于默认可重试码(packages/llm/llm/src/retry-policy.ts:18-24),默认 5 次重试、退避 500ms 起倍增封顶 10s、±10% 抖动(retry-policy.ts:14-17;算式 packages/llm/llm-retry/src/index.ts:59-64,上限判定 :223)。你那条路由的上限看起来是 4,退避也可能被 llm-deepseek: { retryPolicy: … } 覆盖(键在 packages/llm/llm-deepseek/src/config.ts:100,mode: always 可无限重试)(#6987)。

关键一点:被包装的底层错误不会进入会话日志。 turn/end 只保留结构化的 {message, code}(packages/core/agent-loop/src/agent.ts:329-336:LlmError 保留自身 facts,其他才折成 cause 链文本),所以界面上你永远只看得到这一层标签——真正的 cause 链只在启动终端的输出里(#6987)。

分诊后,用只读命令确认证书链(不改任何设置)

powershell
node -e "const t=require('tls');const s=t.connect({host:'api.deepseek.com',port:443,servername:'api.deepseek.com',rejectUnauthorized:false},()=>{const p=s.getPeerCertificate(true);console.log('authorized:',s.authorized,'| authError:',s.authorizationError||'(none)');console.log('leaf:',p.subject.CN,'<- issuer:',p.issuer.CN||p.issuer.O);let x=p,g=0;while(x&&g++<8){if(!x.issuerCertificate||x.issuerCertificate===x){console.log('root:',x.subject.CN||x.subject.O);console.log('rootValidFrom:',x.valid_from);console.log('rootSHA256:',x.fingerprint256);break}x=x.issuerCertificate}s.end()})"

怎么读(#6338):

  1. leaf: … <- issuer: Kaspersky… ⇒ 安全软件正在拦截(这一步本身是正常的);
  2. authError: SELF_SIGNED_CERT_IN_CHAIN 且 issuer 是 Kaspersky ⇒ 就是本文这个问题,按下面的解法处理;
  3. authorized: true ⇒ 已经好了;
  4. 把 rootSHA256 抄下来:下次复发再跑一次,指纹相同 ⇒ 不是根的问题(去查安全软件的拦截规则);指纹不同 ⇒ 根换了,重新导出覆盖即可。

根因:本地 HTTPS 扫描换签证书链,而 Node 不读系统信任库

一句话:安全软件的 HTTPS 扫描必须做中间人才能扫内容,而 Node 故意不用系统信任库、自带一份跨平台一致的根列表——两边的设计撞在一起,就表现为「浏览器正常、DSH 报证书错」。

先分清三张不同的证书

很多「几个 AI 答案不一样」的混乱,都源于把三张证书混在一起讲(#6338):

证书谁签发会不会变
api.deepseek.com 的服务端证书公共 CA(TrustAsia / DigiCert 等)几个月轮换一次,和本次问题无关
安全软件的本地根证书它自己(自签名)安装时生成并存进系统信任库;重装 / 大版本升级会换
每次连接现场签发的叶证书上面那张根随时在变

NODE_EXTRA_CA_CERTS 信任的是第二张(根):只要根没换,它签发的所有叶证书(第三张)自动被信任,不管变多少次。这也解释了为什么「昨天能用、今天不能用」——变的不是 DeepSeek 的证书,而是安全软件的拦截行为(例如开始拦以前不拦的域名)。

证书级证据:根在 Windows 里,却不在 Node 里

社区在 Windows 11(Node v26.7.0、dsh 0.1.5-rc.1)上拿到了完整对照(#6338):

  1. 带 SNI 连接 api.deepseek.com,叶证书是 CN=api.deepseek.com,签发者是 Kaspersky Anti-Virus Personal Root Certificate(O=AO Kaspersky Lab),两个解析地址上都一样;
  2. 这张根确实在 Windows 信任库里(Cert:\LocalMachine\Root / Cert:\CurrentUser\Root,本机共 82 张);
  3. 而 require('tls').rootCertificates 在 Node v26.7.0 上列出 118 张根、0 张匹配 "Kaspersky"——本地拦截根根本不在 Node 自带的 CA 集里;
  4. 默认信任下 tls.connect({...rejectUnauthorized:true}) 抛 SELF_SIGNED_CERT_IN_CHAIN,fetch 同样失败。

所以浏览器正常、DSH 失败——同一个 root,两个信任库,结论完全自洽。

为什么它「时好时坏」而不是每次必现

这是最让报告者困惑的一点,机制其实很清晰(#6420):

是否被换签取决于「这条 TCP 连接」。 undici 连接池里的 keep-alive 连接握手早已完成,不会被劫持;只有新建连接才可能撞上。同一条命令连打多次,结果会不同:

text
connect 1: authorized=false  issuer=[Kaspersky Anti-Virus Personal Root Certificate]
connect 2: authorized=true   issuer=[TrustAsia DV TLS RSA CA 2025]
connect 3: authorized=false  issuer=[Kaspersky Anti-Virus Personal Root Certificate]

实测统计:

场景结果
原始 TLS 握手(直连)6/10 报 SELF_SIGNED_CERT_IN_CHAIN
复用连接发 POST16/20 成功
每个请求都新建连接仅 1–2/20 成功

⇒ 程序空闲一段时间后(连接池需要开新连接)最容易失败,短时间内连续操作反而正常——这就是「没规律」的来源。

同类的「代理链路」问题也会伪装成它:如果你的 Clash 开着 TUN / 增强模式,它是在网络层透明接管流量的,Node 根本不需要任何代理配置,所以「node 没配代理」不代表 clash 不在链路里(#6420)。报告里 ubuntu + clash 关掉就失败、打开就正常、clash 退出后台就失败 都是同一类。

有些版本线是「连接被重置」而非「证书不被信任」

还有一条独立但同源的链:安全软件的加密连接扫描在转发大请求时重置连接。证据是——安全软件暂停的那一刻,同一会话、同一进程、同一网络路径上的传输错误立即归零,此后多个连续请求步无一次重试(#6987)。这类不由证书错误码呈现,而是直接落成 transport failed,所以看到 transport failed 也要把安全软件/代理列为嫌疑。

解法:三种,外加一条禁用项

按「改动范围」从小到大排:单变量读系统库 → 只多信一张根 → 安全软件侧加排除项。不要用关掉全部校验那条路。

方案一(推荐,单变量):NODE_USE_SYSTEM_CA=1

让 Node 直接读系统信任库(安全软件的根本来就在里面)。Node v22.15.0 / v23.8.0 起支持(#6338)。

  1. 确认版本:
powershell
node -v      # 需要 >= v22.15.0
  1. 当前会话临时启用(用于立刻验证):
powershell
$env:NODE_USE_SYSTEM_CA="1"
  1. 长期生效(写进系统环境变量,然后新开终端):
powershell
setx NODE_USE_SYSTEM_CA 1
  1. 重启 DSH(该变量在进程启动时读取)。

为什么推荐它(#6338):

  • 本机实测:不开这个开关时 Node 默认信任 145 张根(全是它自带的),开了之后是 305 张 = 145 张自带 + 160 张系统库——是追加,不是替换;
  • 好处:安全软件以后换根时 Windows 信任库会跟着更新,你什么都不用做;
  • 代价:信任范围从「只多一张根」扩大到「整个系统库」。

社区在不同 Windows 机器上独立复现并验证了同一结论:开启后同一连接 authorized: true,fetch 返回 HTTP 401(没带 key 的正常结果),说明 TLS 已通、请求已到达 API(#6338)。

方案二(最小改动):NODE_EXTRA_CA_CERTS 指向导出的根

只想多信那一张根、不想动系统库时用它。

  1. 在 Windows 上导出拦截根证书:
    1. Win+R 打开 certlm.msc,展开「受信任的根证书颁发机构」→「证书」;
    2. 找名称含 Kaspersky 的那张根证书(通常在列表靠前的厂商区);
    3. 右键 → 所有任务 → 导出 → 选 Base64 编码 X.509 (.CER) → 存为 kaspersky-root.pem。
  2. 启动 DSH 之前指向它:
powershell
$env:NODE_EXTRA_CA_CERTS = "C:\path\to\kaspersky-root.pem"
dsh
  1. 要让所有终端长期生效,就把 NODE_EXTRA_CA_CERTS 写进系统环境变量;PowerShell 永久写法(#6420):
powershell
[Environment]::SetEnvironmentVariable(
  'NODE_EXTRA_CA_CERTS',
  "$env:USERPROFILE\.dsh\certs\interception-roots.pem",
  'User')

它确实生效,不是经验之谈:社区在 Node v22.22.3 上起了一个用自签名证书的本地 HTTPS 服务,同一条命令只改这一个环境变量(#6338):

环境fetch('https://localhost:8443')
不设 NODE_EXTRA_CA_CERTSfetch failed / DEPTH_ZERO_SELF_SIGNED_CERT
设了,指向该证书的 PEMSUCCESS: {"ok":true,"via":"d5-tls-test"}

⇒ Node 的 fetch(undici)确实读取 NODE_EXTRA_CA_CERTS,所以你不需要在「保留扫描」和「访问 API」之间二选一。修复后的真实统计:原始握手 0/10 报错,新建连接请求 20/20 成功(#6420)。

两个坑(#6338):

  1. 路径写错不会报错。 指向不存在的文件时,Node 只打印一条 warning(Ignoring extra certs from …),然后照常失败,错误码还是 SELF_SIGNED_CERT_IN_CHAIN。所以设了仍然失败,先确认路径可达,且文件是 PEM 文本(-----BEGIN CERTIFICATE----- 开头),不是二进制 .cer。
  2. 中间态是里程碑:如果错误码从 SELF_SIGNED_CERT_IN_CHAIN 变成了 ERR_TLS_CERT_ALTNAME_INVALID,说明证书已经被信任了,只剩主机名不匹配——那是另一个问题(证书 SAN 与访问域名/IP 不一致),与安全软件无关。

方案三(留在安全软件侧):加排除项 / 受信任应用

不想碰 Node 配置,也可以让这段流量不被扫描(#6338):

  1. 把 dsh / node 加入安全软件的受信任应用(跳过其加密连接扫描);
  2. 或关闭「加密连接扫描 / HTTPS 扫描」;
  3. 或在扫描设置里把 api.deepseek.com 加进排除项,让这一段不被扫描(菜单名以你的版本为准,卡巴斯基官方说明:https://support.kaspersky.com/kaspersky-for-windows/21.23/157530)。

说明:第 1、2 项是安全软件 UI 侧配置,社区作者坦承没有安全软件环境逐一实测;第 3 项的官方文档链接可用作入口。

⛔ 不要用 NODE_TLS_REJECT_UNAUTHORIZED=0

实测它确实能连通(fetch 返回 200),但它关闭的是全部证书校验,Node 自己会打印安全警告。救急可以,不该当成解法(#6338)。

macOS 用户:持久化用 LaunchAgent

macOS 上同样会遇到(报错常是 UNABLE_TO_GET_ISSUER_CERT_LOCALLY),因为 DSH 内置的 Node 没有使用 macOS 系统 CA。社区给出的完整修复记录是(#6987):

  1. 在当前登录会话启用 NODE_USE_SYSTEM_CA=1;
  2. 安装持久化 LaunchAgent:~/Library/LaunchAgents/com.deepseek.harness.system-ca.plist;
  3. 重启 DeepSeek Harness;
  4. 未修改 API Key、模型配置或会话数据。

验证结果:内置 Node 能访问 Messages API 并返回预期的 HTTP 401(而非 TLS 失败),实际会话恢复流式输出 约 250–280 tok/s,新一轮执行 20 多个步骤、TRANSPORT 错误数为 0。

排查注意事项

这类问题的核心是「先确定报错落在哪一层」,最忌讳一上来就重装 DSH 或删 .dsh 目录。 八条要点:

  1. 先跑最小复现命令再动任何东西:node -e "fetch('https://api.deepseek.com')..."。出现 SELF_SIGNED_CERT_IN_CHAIN ⇒ TLS 信任链问题;出现其他 ⇒ 另说(#6338)。
  2. 别把「时好时坏」当随机故障:「空闲后失败、连续操作正常」正是 keep-alive 连接的指纹——只有新建连接才会撞上换签(#6420)。
  3. 「node 没配代理」不等于代理不在链路里:Clash 的 TUN / 增强模式在网络层透明接管,Node 不需要任何代理配置也会被路由;给域名加一条 DIRECT 规则即可验证(#6420)。
  4. 卸载安全软件后的实验不能回溯故障时刻:rejectUnauthorized:false 仍会记录校验结果,但你在「干净状态」下跑只能证明「现在是干净的」(#6338)。
  5. 两种报错都要想到安全软件:证书不被信任报 SELF_SIGNED_CERT_IN_CHAIN;加密扫描转发大请求时重置连接则直接落成 transport failed,没有证书错误码(#6987)。
  6. transport failed 的真正 cause 在启动终端:会话日志的 turn/end 只留 {message, code},被包装的底层错误不会写进日志,界面永远只显示最外一层标签(#6987)。
  7. 这不是 bug,两边都不会「自己修好」:安全软件的 HTTPS 扫描必须做中间人才能扫内容,而 Node 故意使用自带的跨平台根列表。同类问题会出现在任何自带信任库的程序(Python / Java / Go …)搭配任何做 HTTPS 拦截的软件(ESET / Bitdefender / 企业代理如 Zscaler)上(#6338)。
  8. 别忘了看 DSH 版本与 baseURL:若 curl 直连成功、只有 DSH 失败,检查 settings 里 llm-deepseek 的 baseURL 是否被改过、环境里有没有 DEEPSEEK_BASE_URL、key 是否被 DEEPSEEK_API_KEY 正确注入(#6420)。

排查这类问题时,用 DSH Plugin Hub 的已安装列表确认 DSH 侧版本与插件状态、并在系统日志页把诊断信息导出来,能先把「DSH 没装好」这一层快速排除,把精力留给证书链本身。

DSH Plugin Hub · 系统日志

来源:Discussion #6338、Discussion #6420、Discussion #6987。

常见问题

DSH 报 SELF_SIGNED_CERT_IN_CHAIN,是 DeepSeek 服务端的证书坏了吗?

不是。这个错误码不是 DSH 生成的,而是 Node TLS 层的内置错误,说明被替换的是你本机这一段证书链:安全软件或代理做了 HTTPS 中间人扫描,用它自己的自签根证书重新签发了证书链,而 Node 的 fetch(undici)默认不信任这张根。实测关掉扫描就恢复直连、错误消失,正好反证问题在本地这一段,与 api.deepseek.com 服务端证书无关。

为什么这个错误时好时坏、重试有时又能成功?

因为是否被换签取决于「这条 TCP 连接」。undici 连接池里已建立的 keep-alive 连接握手早已完成、不会被劫持;只有新建连接才可能撞上。实测原始 TLS 握手 6/10 报错、复用连接发 POST 16/20 成功、每个请求都新建连接则只有 1-2/20 成功。所以程序空闲一段时间后(连接池要开新连接)最容易失败,短时间内连续操作反而正常——这就是「没规律」的来源。

NODE_USE_SYSTEM_CA 和 NODE_EXTRA_CA_CERTS 该选哪个?

想省事、以后不想再管,选 NODE_USE_SYSTEM_CA=1(Node v22.15.0 / v23.8.0 起支持):Node 直接读系统信任库,本机实测根证书从 145 张(自带的)追加到 305 张(+160 张系统库),是追加不是替换;安全软件以后换根时系统库会跟着更新,你什么都不用做。只想做最小改动、或 Node 版本较低,就用 NODE_EXTRA_CA_CERTS 指向导出的那张 PEM,只多信这一张。两个选一个即可,长期生效都要写进系统环境变量或持久化配置。

设了 NODE_EXTRA_CA_CERTS 还是报同样的错,怎么排查?

最常见是路径或文件格式问题:指向不存在的文件时 Node 只打印一条 warning(Ignoring extra certs from …)然后照常失败,错误码还是 SELF_SIGNED_CERT_IN_CHAIN。请确认路径可达,且文件是 **PEM 文本**(以 -----BEGIN CERTIFICATE----- 开头),不是二进制 .cer。另一个有用的中间态:若错误码变成 ERR_TLS_CERT_ALTNAME_INVALID,说明证书**已经被信任**,只剩主机名不匹配,那是另一个问题。注意该变量只在进程启动时读取,改完必须重启 DSH。

DSH 只显示「DeepSeek Messages transport failed」,看不到底层原因怎么办?

这条串是 DSH 自己的分类标签:当请求生成器抛出的**不是 LlmError** 时才会落到这个分支(adapter.ts:76-79),含义是请求或响应流在 SSE/协议层之下断了——连接被重置、链路/代理中断、TLS 或 HTTP2 层失败。协议层的其他失败都有自己的错误码(TIMEOUT / ABORTED / AUTH / MALFORMED_RESPONSE / STREAM_CLOSED 等),不会落进这条。被包装的底层错误不会进入会话日志,所以要去**启动终端的输出**里找 cause 链。

相关术语

SELF_SIGNED_CERT_IN_CHAIN
Node TLS 层的内置错误码,表示握手中收到的证书链里出现一张自签名证书,且它不在本地信任库里。在本文语境下典型来源是安全软件的 HTTPS 扫描用其自签根证书重签了证书链;DSH 自己并不生成也不映射这个码,只是把它包进 TRANSPORT 类错误。— https://github.com/deepseek-ai/deepseek-harness/discussions/6338
NODE_USE_SYSTEM_CA
Node 环境变量,取值 1 时让 Node 直接使用操作系统信任库(Windows 证书存储 / macOS 钥匙串),而不是只用自带的跨平台根列表。Node v22.15.0 / v23.8.0 起支持;本机实测开启后信任根从 145 张追加为 305 张。好处是企业/安全软件的拦截根随系统库自动更新,代价是信任范围扩大到整个系统库。— https://github.com/deepseek-ai/deepseek-harness/discussions/6338
NODE_EXTRA_CA_CERTS
Node 环境变量,指向一个额外的 PEM 证书文件,让 Node 在该进程内额外信任其中的根证书。它在进程启动时读取一次,改完必须重启程序;指向不存在的文件时只打印 warning、不会报错。相比 NODE_USE_SYSTEM_CA 是更小范围的改动,只多信指定的那一张根。— https://github.com/deepseek-ai/deepseek-harness/discussions/6338
DeepSeek Messages transport failed
DSH 对 DeepSeek Messages 协议失败的分类文案,只在请求生成器抛出非 LlmError 时产生(`packages/llm/llm-deepseek/src/protocols/messages/adapter.ts:76-79`),对应可重试的 TRANSPORT 类错误。协议层/HTTP 层失败各有自己的错误码,不会落进这条;被包装的底层 cause 不会写入会话日志,只会出现在启动终端。— https://github.com/deepseek-ai/deepseek-harness/discussions/6987

来源