内网部署 DeepSeek Harness 接入 glm5.3 网关
内网没有外网, 但有:
一台大模型网关, 给了 OpenAI 兼容接口 和 Claude(Anthropic Messages) 对接;
模型
glm5.3;一台能跑 codeagent 的 Linux 机器。
本文把这台机器变成一台能用的 DSH(DeepSeek Harness): dsh headless 能干活,
dsh web 能起 GUI, 模型走内网网关的 glm5.3。
如果这一步是要交给内网的 AI 编码 agent 去执行, 直接给它 AGENT-TASK.md —— 那是按"agent 可执行"写的任务书: 前置检查、分支决策、硬约束、结构化回报模板, 以及不依赖脚本的手工兜底路径。 本文则是给人看的完整方案(原理、实测证据、排查表、compat 归属表)。
结论先行
问题 |
结论 |
|---|---|
用哪种协议接 |
优先 OpenAI 兼容(pi-ai 路由)。GLM 系有现成方言 |
Claude 对接能用吗 |
能用。它走 DSH 自家的 Messages 适配器, 但默认会带 3 个 DeepSeek 私有字段, 要按探测结果关掉 |
配置写在哪 |
|
密钥写在哪 |
|
怎么确认成功 |
|
前置条件
node -v # 必须 v22 及以上(见下)
python3 -V # 脚本只用标准库; 3.8+ 均可
dsh --version
语言环境不用管: 内网机器若是 LANG=zh_CN.GBK 这类非 UTF-8 locale, 脚本会自动
用 Python 的 UTF-8 模式(PYTHONUTF8=1)。这不是可选项 —— 实测 GBK locale 下
Python 会把"文件系统编码"也当 GBK, 结果 ✅/❌ 打印不出来(UnicodeEncodeError),
argv 里的中文还会被 surrogate-escape 成孤立代理字符, 写配置文件时再崩一次。
Node 版本是硬门槛, 而且低版本的失败方式是静默的: 实测
Node 20.20.2 上 dsh 退出码 0、stdout/stderr 都是 0 字节、连模型请求都不发,
看起来就像"跑完了什么都没干"; 换成 Node 22.23.3 立刻全绿。所以脚本会在
doctor 和每次 smoke 前检查 node 主版本, 低于 22 直接标 ❌ 并给出这个解释。
内网机器 node 太旧的话, 把 Node 官方静态包 一起带进去
(tar -xJf node-v22.x-linux-x64.tar.xz 后把 bin/ 加进 PATH, 不用装系统包)。
没有 dsh 的话先装, 两条路:
# A. 内网有 npm 源(Nexus/Verdaccio/内网 registry)
./dsh-intranet.sh install --registry http://npm.intranet/repository/npm/
# A'. 没有 root / 不想装到全局(内网机器常见): 装进自己目录
./dsh-intranet.sh install --prefix ~/dsh --registry http://npm.intranet/repository/npm/
# 装完会在 ~/.local/bin/dsh 挂一个软链, 脚本自己认这个入口, 不加 PATH 也能用
# B. 内网完全离线: 在有外网的机器上先打包, 再拷进来
./dsh-intranet.sh bundle --out /tmp/dsh-offline.tar.gz # 在联网机器上
./dsh-intranet.sh install --bundle /tmp/dsh-offline.tar.gz # 在内网机器上
--prefix这条路本机实测过:npm install --prefix <dir>得到 496 MB 的node_modules, 里面的dsh能直接跑, 拿它做smoke也通。
离线包本质是
node_modules整包, 和 CPU 架构 / glibc 版本绑定, 目标机同架构才能直接用。 本机实测: 496 MB 的node_modules打成 117 MB 的 tar.gz, 解包后dsh --version正常,selftest也全绿(见下文"本机验证")。 另外实测: profile 首次初始化不需要联网 —— 新建的$DSH_HOME/profiles/web/里dependencies是空的,node_modules也不会生成, 随附 bundle 直接从 DSH 安装目录解析。所以只要 DSH 装上了, 断网起dsh web没问题。包不含 Node 运行时 —— 内网机器得自己有 node(且 ≥22), 没有的话再带一份 Node 官方静态包 进去。取不到外网又要跑 DSH, 更省事的做法是在内网 registry 上代理一份
@deepseek-ai/dsh。
三步接通
cd ai/dsh-intranet
# 1. 探测: 网关在哪个路径、两种协议哪种能用、哪些字段会被拒
./dsh-intranet.sh probe --url http://10.0.0.9:8000 --key sk-xxx
# 2. 配置: 按探测结论写 cordis.patch.yml + .env, 并校验这份配置真能加载
./dsh-intranet.sh configure --url http://10.0.0.9:8000 --key sk-xxx --model glm-5.3
# 3. 冒烟: 真跑一次
./dsh-intranet.sh smoke
# 顺手看看写了什么(密钥打码)
./dsh-intranet.sh show
# 一步体检(环境/配置/网关/冒烟), 输出可以整段贴回来
./dsh-intranet.sh doctor --url http://10.0.0.9:8000 --key sk-xxx
doctor 是给"出问题要找人看"准备的一条命令: 它把系统与 node/python3 版本、
dsh 位置与版本、DSH_HOME、受管块在不在、密钥文件权限与变量名、
网关探测结论、冒烟结果一次性列全, 最后给出 ✅/❌ 汇总, 且永远不写配置。
probe 会逐个试探这些字段, 把"网关接受什么"变成实测事实而不是猜测:
协议 |
试探项 |
|---|---|
OpenAI 兼容 |
最小 chat 请求、 |
Claude 对接 |
最小 messages 请求、 |
公共 |
|
被 4xx 拒绝的项, 会自动翻译成对应的 compat 开关写进配置; Claude 路由被拒私有字段,
就自动关掉三个 DeepSeek 扩展插件。探测请求的 max_tokens 只有 8, 花不了多少额度。
--api openai|anthropic 可以强制选一种(默认 auto: 能用 OpenAI 兼容就用它)。
配置落在哪里
DSH 的配置是"多层 patch 依次叠加":
@deepseek-ai/dsh-base 等 bundle 层
↓
$DSH_HOME/profiles/<profile>/cordis.patch.yml (profile 层)
↓
$DSH_HOME/cordis.patch.yml (home 层) ← 脚本只动这里
↓
dsh --patch extra.yml (命令行覆盖层)
home 层对 所有 profile 生效, 所以改一次, dsh headless 和 dsh web 一起生效
(实测 --dump-config 两个 profile 里都能看到 intranet-gw)。
脚本写进去的是一段被注释包起来的受管块, 重复执行只会替换这一段, 不会越写越多:
# >>> dsh-intranet managed block (由 dsh-intranet.sh 维护, 手改会被覆盖) >>>
- id: llm-pi-ai
config:
providers:
intranet-gw:
displayName: 内网网关
api: openai-completions
baseURL: http://10.0.0.9:8000/v1
apiKeyEnv: INTRANET_LLM_API_KEY
compat:
thinkingFormat: zai
defaultContextWindow: 204800
defaultMaxTokens: 32768
models:
- id: glm-5.3
name: glm-5.3
contextWindow: 204800
maxTokens: 32768
- id: agent-default-model
config:
provider: intranet-gw
model: glm-5.3
# <<< dsh-intranet managed block <<<
每次写入前都会备份成 cordis.patch.yml.bak-<时间戳>; 如果 DSH 组装配置时报错,
脚本会自动回滚并打印报错原文。
自签 HTTPS 网关: 探测与 DSH 是两套 TLS
内网网关常用自签证书, 这里有个很容易误判的坑: probe --insecure 只让 探测
(Python)不校验证书, 而模型请求是 DSH 这个独立 Node 进程发出的, 它按自己的
证书库校验。于是会出现:
probe ✅ 可用协议: openai-completions
configure ✅ 配置已写入、校验通过
smoke ❌ dsh: TRANSPORT: Connection error. ← 看起来像防火墙/端口问题
DSH 不会告诉你是证书问题。正确做法是给两边同一个 CA:
./dsh-intranet.sh configure --url https://gw.corp --key sk-xxx --ca-file /path/to/ca.pem
./dsh-intranet.sh smoke --ca-file /path/to/ca.pem
# 或者永久生效(注意: NODE_EXTRA_CA_CERTS 属于"启动引导变量", 只能从环境来, 不能写 .env)
echo 'export NODE_EXTRA_CA_CERTS=/path/to/ca.pem' >> ~/.bashrc
实在拿不到 CA 时的下策是 export NODE_TLS_REJECT_UNAUTHORIZED=0(全局关校验,
自己权衡)。smoke 失败时会主动验一次证书再下结论, 所以上面那种误判不会再发生。
密钥的解析顺序
DSH 的凭据按固定顺序取, 先命中先赢:
顺序 |
来源 |
谁能写 |
|---|---|---|
1 |
启动环境( |
你, 每次启动 |
2 |
|
配置界面 |
3 |
当前目录的 |
项目 |
4 |
|
脚本写这里 |
脚本写 4, 权限 600。想用 CI 注入就加 --no-key-file, 自己在启动时 export。
两条路由的实测差异
同一台机器、同一个 mock 网关, DSH 0.1.7-rc.2 实际发出的请求体:
OpenAI 兼容路由(pi-ai) |
Claude 对接路由(llm-deepseek) |
|
|---|---|---|
路径 |
|
|
鉴权头 |
|
|
默认输出上限字段 |
|
|
请求体顶层字段 |
|
|
私有字段 |
无 |
3 个 DeepSeek 专属字段, 严格网关会 400 |
模型目录 |
自己声明的 |
自己声明的 |
思维链方言 |
|
|
两条都能跑通(本仓库的 selftest 会各跑一遍)。GLM 系建议走 OpenAI 兼容:
请求体里没有 DeepSeek 私有字段, 且 pi-ai 原生认识 GLM/Zhipu 的思维链方言。
compat 开关是按协议分的(实测)
compat 的键不是通用开关。放错协议的后果是启动即报错:
dsh: INVALID_CONFIG: llm-pi-ai: provider "intranet-gw" sets compat
"supportsStrictTools", but no model on the route speaks a protocol
that takes it; it exists on anthropic-messages
要命的是 --dump-config 查不出来 —— 它只组装配置树、不加载插件。实测
(dsh 0.1.7-rc.2) 两边各认哪些键:
协议 |
合法键 |
|---|---|
|
|
|
|
( |
|
本方案里 Claude 路由走 llm-deepseek, 没有 compat 面, 所以这张表主要约束
OpenAI 兼容路由。probe_gateway.py 里内置了这份白名单: 只生成目标协议认的键,
不会把 supportsStrictTools 这类 anthropic 专属开关写到 openai-completions 上。
因为 --dump-config 挡不住这类错误, configure 的第 4 步除了组装配置树, 还会
把刚写的配置复制一份、把 baseURL 改成死地址(127.0.0.1:1)真启动一次:
配置有问题会在任何网络 I/O 之前抛 INVALID_CONFIG(于是回滚并打印那一行),
配置没问题则只会得到 TRANSPORT(连不上死地址)。这样校验不碰真网关、不发
模型请求, 却能把插件级错误挡在配置落盘之后、冒烟之前。
实测踩过的坑
.env里不能放DSH_开头的变量名。DSH 把DSH_/XDG_/DYLD_前缀, 以及PATH/HOME/NODE_*/LD_*视为"启动引导变量", 只允许由启动环境设置。 实测直接拒绝启动:Error: dsh: /home/me/.dsh/.env sets "DSH_INTRANET_API_KEY", which only the launching environment may set (it decides how this process starts, where its code and instructions load from, or how it reaches the network); export DSH_INTRANET_API_KEY instead of putting it in a .env file
所以默认变量名是
INTRANET_LLM_API_KEY, 脚本也会拒绝--key-var DSH_*。 (顺带: home 层的.env允许写HTTP_PROXY等代理变量, 项目层.env不允许。)Claude 路由默认输出上限是 256000。GLM 或中转网关通常没这么大, 会被 400 顶回来。 配置里显式写
maxTokens(默认 32768), 并保证contextWindow > maxTokens + 压缩余量, 否则开了主动压缩会拒绝启动。Claude 路由默认会带 DeepSeek 私有字段:
output_config、dsh_session_log、dsh_plugin_packages。DeepSeek 官方网关认识, 别人的 Claude 兼容网关多半不认识。 处置就是配置里reasoningEffort: off+ 关掉三个扩展插件:- id: deepseek-llm-api-extensions disabled: true - id: session-log-deepseek disabled: true - id: plugin-package-inventory-deepseek disabled: true
关掉之后请求体只剩
max_tokens / messages / model / stream / system / thinking / tools, 是一份干净的 Anthropic Messages 请求(实测)。baseURL的/v1两家语义不同。llm-deepseek自己会追加/v1/messages(末尾正好是/v1才复用); pi-ai 的openai-completions是直接拼{baseURL}/chat/completions和{baseURL}/models, 所以 baseURL 得含/v1。 脚本按这个约定归一化:--url填http://gw:8000或http://gw:8000/v1都行。内网代理会劫持探测。机器上若 export 了
http_proxy, 探测内网地址会绕一圈。probe默认绕过代理直连内网; 确实要走代理再加--use-proxy。自签证书。内网网关常用自签 HTTPS, 加
--insecure跳过校验。
故障排查
smoke 失败时按现象对表:
现象 |
多半是 |
处置 |
|---|---|---|
启动就 |
|
按上面的归属表改; |
退出码 0 但一个字都没输出 |
node 版本过低(高发) |
|
|
没拿到 key |
看 |
|
key 不对, 或鉴权头不对 |
用 |
404 / not found |
路径不对 |
|
400 |
网关拒了某个字段 |
重跑 |
|
地址、端口、防火墙、代理, 或自签证书 |
先 |
模型回话但内容是乱的 |
思维链方言不对 |
GLM 系在 OpenAI 路由里加 |
GUI 里选不到模型 |
目录里没有这个 id |
|
备注
机器上设了 http_proxy 时, Node 22 会打印一行
[UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental。它只是告警, 不影响请求;
嫌吵就在启动 DSH 时带上 NODE_NO_WARNINGS=1(脚本内部调用已自动压掉)。
不依赖脚本的手工配置
脚本只是把下面两件事做完了, 内网不方便跑 Python 时照抄即可。
第一步, 把配置块追加到 $DSH_HOME/cordis.patch.yml(没有这个文件就新建,
它就是一个顶层 YAML 数组); 第二步, 把 key 放进 $DSH_HOME/.env 并 chmod 600:
echo 'INTRANET_LLM_API_KEY=sk-xxx' >> ~/.dsh/.env && chmod 600 ~/.dsh/.env
dsh --profile headless --dump-config | grep -A6 'id: llm-pi-ai' # 能看到内网路由就对了
dsh --profile headless "只回答两个字:正常" # 真跑一次
Claude 对接版(把上面受管块换成这段):
- id: deepseek-llm-api-extensions
disabled: true
- id: session-log-deepseek
disabled: true
- id: plugin-package-inventory-deepseek
disabled: true
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek-api-key'
config:
apiKeyEnv: INTRANET_LLM_API_KEY
baseURL: http://10.0.0.9:8000
maxTokens: 32768
reasoningEffort: off
models:
- id: glm-5.3
name: glm-5.3
contextWindow: 204800
- id: agent-default-model
config:
provider: deepseek-official
model: glm-5.3
--profile 的用法: 校验/冒烟固定用 headless
doctor 与 smoke 里的"跑一句看模型回不回话"只能在 headless profile 上做 —— 它是
唯一"跑完就退出"的 profile。web/acp/sdk 会常驻, 拿不到结论还可能占住端口,
所以 smoke --profile web 会明确拒绝并告诉你去做人工自检, 而不是挂在那里等超时。
configure 的配置校验也固定用 headless 启动一次: 配置写在 home 层, 对所有 profile
是同一份, 校验等价。而且这次校验现在要求"要么看见 INVALID_CONFIG, 要么看见它真的
走到发请求/取凭据那一步"—— 否则算校验失败。之前 --profile web 会让这次启动报
too many arguments, 旧的判断只看 INVALID_CONFIG, 于是把"没校验成"当成了"校验通过"。
Web GUI 也吃同一份配置
dsh web 用的就是那个 home 层 patch, 不用另配一份。实测:
DSH_HOME=/tmp/webhome ./dsh-intranet.sh configure --url http://10.0.0.9:8000 --key sk-xxx
dsh web --host 0.0.0.0 --port 3080 --no-open
# 启动日志会打印带 token 的地址; 打开它, 在模型选择器里挑「内网网关 / glm-5.3」
本机验证结果: dsh web 用内网配置正常启动并打印
http://127.0.0.1:3901/?token=…; 带 token 访问返回 303 换成 cookie,
不带 token 返回 401; 前端 index.html(34 KB)与 JS/CSS 资源都是 200。
也就是说 GUI 侧的"进程起得来、认证生效、静态资源齐、配置已加载"这四项都过了,
模型请求本身走的是和 headless 完全相同的那条 LLM 路由(见下)。
一条命令验收工具箱: verify
./dsh-intranet.sh verify
它用几个 mock 分别扮演不同的网关, 把下面 10 项跑成 ✅/❌ 矩阵, 全程不碰真网关、不花额度:
# |
用例 |
期望 |
|---|---|---|
1 |
|
全绿 |
2 |
|
降级后仍可用 |
3 |
|
非 UTF-8 locale 不崩 |
4 |
只开 OpenAI 的网关 |
选 |
5 |
只开 Claude 的网关 |
选 |
6 |
强制网关不支持的协议 |
写文件前干净退出, 不留半成品 |
7 |
|
配置与 |
8 |
|
不写 |
9 |
用户 home patch 已有无关配置 |
原条目保留, 两者共存可跑 |
10 |
窗口小于输出上限 |
被拒绝 |
11 |
自签 HTTPS 网关(有 openssl 才跑) |
|
12 |
|
当场拒绝并给出 GUI 人工自检指引(不能挂住) |
13 |
任务书附录 B 里手写的 YAML |
原样可用(改了生成器没改文档就会被这条抓住) |
内网机器上先跑这一条确认工具箱本身没问题, 再去碰真网关 —— 这样失败时就能立刻区分 "是脚本/环境的问题"还是"是网关的问题"。
本机验证: selftest
内网网关不一定随时能动, 所以这套脚本自带一个 mock 网关, 断网也能验证整条链路:
./dsh-intranet.sh selftest
它做四件事: 起 mock_gateway.py → 对 mock 跑 probe → 在临时 DSH_HOME 里
configure → smoke, OpenAI 兼容与 Claude 对接两条路由各走一遍。
全绿说明"配置生成 → 凭据解析 → 协议转换 → 模型回话"这条链没有断,
剩下的风险就只有真网关的行为了。最后还会打印网关侧实际收到的请求:
==> 网关侧看到的请求
DSH 发出的请求 (共 4 条):
/v1/chat/completions x2
请求体字段: max_completion_tokens, messages, model, store, stream, stream_options, tools
/v1/messages x2
请求体字段: dsh_plugin_packages, dsh_session_log, max_tokens, messages, model, output_config, stream, system, thinking, tools
探测器发出的请求 (共 32 条):
...
统计按 User-Agent 把"DSH 自己发的"和"探测器发的"分开, 免得把探测请求的字段
误当成 DSH 的字段。上面这份是 mock 网关什么都接受 的结果, 所以 DeepSeek 私有
字段被保留了下来; 真网关若拒绝它们, 配置里会自动多出三个 disabled: true。
mock_gateway.py 同时提供 POST /v1/messages(Anthropic)和
POST /v1/chat/completions(OpenAI), 并把 DSH 发来的每个请求体原样落到
/tmp/mock-gateway-requests.jsonl —— 想知道 DSH 到底发了什么字段, 看这个文件最快。
内网网关的形态不止一种, 所以三种都在本机演练过一遍(用 mock 分别扮演):
只开 OpenAI 兼容、只开 Claude 对接、严格网关(拒私有字段与方言) ——
三种情况下 configure + doctor 都全绿; 强制指定网关不支持的那种协议时,
configure 会在写任何文件之前退出并提示换 --api。此外还演练过:
用户 $DSH_HOME/cordis.patch.yml 已有无关配置(原条目保留、两者共存)、
已有同 id 的 llm-pi-ai 条目(追加的受管块生效)、--no-key-file(不写 .env,
靠启动环境提供凭据)、--key-var(自定义凭据变量名)。
selftest --strict 让 mock 扮演严格网关: 它会 400 掉
max_completion_tokens / store / stream_options / reasoning_effort /
developer 角色 / 工具 strict, 以及 Anthropic 侧的所有私有顶层字段。
这一轮跑通, 说明"探测发现被拒 → 关掉对应 compat 开关与插件 → 仍然能干活"
的降级路径是通的。实测严格模式下 DSH 最终发出的请求体收敛成:
/v1/chat/completions x2 请求体字段: max_tokens, messages, model, stream, tools
/v1/messages x2 请求体字段: max_tokens, messages, model, stream, system, thinking, tools
两条路由都验证过之后, 用离线解包出来的那份 DSH再跑一遍 selftest
(把 HOME 指到一个干净目录, 模拟内网目标机), 一样全绿 ——
说明"打包 → 拷进去 → 解包 → 配好 → 跑通"这条离线路径本身没有坑。
备注
selftest 用临时 DSH_HOME, 不会碰你正在用的 ~/.dsh。
文件清单
文件 |
作用 |
|---|---|
|
主入口: |
|
网关探测器, 输出推荐配置(零依赖) |
|
内网网关模拟器, 供离线验证; |
|
统计 mock 收到的请求路径与字段, 用于核对协议 |
|
把任务书里手写的 YAML 抽出来实跑, 防止文档与生成器脱节 |