Dify 连局域网 Ollama 的两个坑:docker0 网段撞车,和请求里少掉的那个参数
场景:两台 Windows 机器,A 机跑 Ollama 0.40.15(Qwen3.5 4B),B 机用 Docker Desktop 自部署 Dify 1.17.1。 目标:让 B 机上的 Dify 真正跑通 A 机上的模型,并且不要让用户等两分钟(B是一台古董,见上篇文章)。
过程中出现过十几种报错,但真正值得写下来的只有两个根因。一个属于"通不通",一个属于"快不快",它们完全在不同的层。
0. 先看清拓扑:你以为的两层,其实是三层
很多人(包括我)第一反应是"Dify 在 B 机,Ollama 在 A 机,两个 IP 一通不就行了"。实际不是:
css
局域网 172.17.16.0/24
A 机 172.17.16.52 B 机 172.17.16.46
┌──────────────────────┐ ┌───────────────────────────────┐
│ Ollama 0.0.0.0:11434│◄────┐ │ Docker Desktop(WSL2 虚拟机) │
│ │ │ │ ┌─────────────────────────┐ │
└──────────────────────┘ │ │ │ Dify 的 15 个容器 │ │
└────┼──┤ 内部虚拟地址 172.18.x 等 │ │
│ └─────────────────────────┘ │
│ ▲ 这一层外面看不见 │
└───────────────────────────────┘
Dify 并不直接住在 B 机的操作系统里,它住在 B 机内部那台 WSL2 虚拟机里。 容器有自己的一套虚拟 IP(如 172.18.0.0/16、172.20.0.0/16),这套地址出了虚拟机就不存在。
Dify 出门,只走一条路:先过 docker0
Dify 不是一个进程,是 15 个容器(api、worker、web、nginx、postgres、redis、weaviate......)。它们之间用 Docker 自定义网络(docker_default,容器地址 172.20.0.x)互通,这套地址只在虚拟机内部有效。
容器要访问虚拟机外面的东西(比如 A 机上的 Ollama),包必须按这条链走:
erlang
容器 172.20.0.5
│ 目标不在自己网段 → 交给默认网关
▼
docker_default 网桥网关 172.20.0.1
│ 进入 WSL2 虚拟机内核,由"路由表"决定去向
▼
虚拟机路由表 ← docker0 的网段就写在这里
│ 命中哪条路由,就走哪张网卡
▼
eth0 (172.27.153.60) ── NAT ──> 宿主 Windows ──> 局域网 172.17.16.x
把各层的角色摆在一起看:
| 角色 | 在哪 | 典型地址 | 干什么 |
|---|---|---|---|
| Dify 的 15 个容器 | WSL2 虚拟机内 | 172.20.0.x |
真正跑 Dify 的地方 |
docker_default 网桥 |
虚拟机内核 | 网关 172.20.0.1 |
容器之间的内部交换机 |
| docker0 | 虚拟机内核 | 网关改前 172.17.0.1 |
Docker 守护进程启动时必然创建的默认网桥,它的网段会写进虚拟机路由表 |
eth0 |
虚拟机 | 172.27.153.60/20 |
虚拟机自己的网卡,通到宿主 |
vEthernet (WSL) |
宿主 Windows | 172.27.144.1 |
上面那块网卡在 Windows 侧的另一半 |
| 局域网 | 真实网络 | 172.17.16.0/24 |
A 机 172.17.16.52 在这里 |
一句话记住:docker0 不是"Dify 的网络",但它决定了 Dify 的包出不出得去。容器自己的路由表只负责把包交给网关,真正的分流发生在虚拟机内核的路由表里,而 docker0 的网段就住在那儿。
理解了这一点,下面两个坑就都好解释了。
一、坑一:添加模型报 connect timeout,不是防火墙,是"门牌号"撞了
现象
在 Dify 的 Ollama 供应商里填好基础 URL,点"添加模型",弹出来的是:
ini
HTTPConnectionPool(host='172.17.16.52', port=11434)
Max retries exceeded ... connect timeout=10
TCP 三次握手就没完成。注意是 timeout,不是 refused ------ 说明包出去了,但没人应答。
先按常规思路排除了这些
| 检查项 | 结果 |
|---|---|
| Ollama 是否监听 | 0.0.0.0:11434、[::]:11434 均 LISTENING |
| 是否只绑了 127.0.0.1 | 已经是 OLLAMA_HOST=0.0.0.0:11434 |
| Windows 防火墙 | 入站规则 Allow / Inbound / Profile=Any 都在 |
| 两台机能否互 ping | 通 |
到这里,"网络没问题"几乎可以下结论了 ------ 而且这个结论只对了一半 :网络没问题,但路由有问题。
转折点是两个"对照实验"
实验一:同一台机器,不同位置的进程
- 宿主机上的程序(WorkBuddy)访问
172.17.16.52:11434→ 通 - 容器里的 Dify 访问同一个地址 → 超时
同一目标、同一端口、同一时刻,唯一差别是"发请求的进程在不在容器里"。这直接把嫌疑钉在了容器侧。
实验二:换成"隔壁那台机"
在容器里访问 B 机自己 的 172.17.16.46:80(nginx 就在这台上)→ 也超时。
这是最短的一段路,居然也不通。说明问题与目标主机无关,是 172.17.16.x 这一整段都出不去。
定位:三条命令
bash
# 1. Docker 默认网桥的网段
docker network inspect bridge --format '{{.IPAM.Config}}'
# 输出: [{172.17.0.0/16 ...}] ← 出厂默认
# 2. 容器里的路由表(关键证据)
docker exec docker-api-1 cat /proc/net/route
# eth0 172.18.0.0/16
# eth1 172.20.0.0/16
# 3. 宿主机的局域网网段
# 172.17.16.0/24 ← 撞上了
根因:docker0 把局域网段"认领"成了本机网段
Docker Desktop 默认给 docker0 分配 172.17.0.0/16。这条网段会进入虚拟机的内核路由表,语义是:"凡是 172.17.x.x,都是我本机网桥内的事,走 docker0。"
看一眼就知道:
bash
wsl -d docker-desktop ip route
# 改前:172.17.0.0/16 dev docker0 ← 局域网整段被这条截走
# 改后:172.31.0.0/16 dev docker0
这里有个最容易看错的地方:进容器里查路由表,一切正常 ------里面只有 172.18.0.0/16、172.20.0.0/16 和自己的 default via 172.20.0.1。目的地不在自己网段,容器老实把包交给了网关,这一步没错。错在包进了虚拟机之后:虚拟机路由表里有一条比默认路由更精确的 172.17.0.0/16 dev docker0,本该交给 eth0 出局的包,被塞进了本地网桥。
而你的局域网恰好是 172.17.16.0/24 ------ 完全落在这个范围里。docker0 上只有它自己的内部成员,172.17.16.52 不在其中,包就此消失。结果就是超时,且没有任何报错。
为什么宿主机进程不受影响?因为它在 Windows 侧,用的是真实网卡的路由表,根本不知道 docker0 的存在。
修复:只改 Docker 的"内部编号",两行
文件:%USERPROFILE%\.docker\daemon.json
json
{
"bip": "172.31.0.1/16",
"default-address-pools": [
{ "base": "172.30.0.0/16", "size": 24 }
]
}
两个字段的含义(这是整篇文章最容易被搞混的地方):
| 字段 | 管什么 |
|---|---|
bip |
docker0 网桥自己的地址段。改它,等于把那条"172.17 全归我"的路由挪走 |
default-address-pools |
以后新建自定义网络时从哪里切分(每个 /24) |
两处故意错开(31 / 30),避免它们互相抢地址。其余原有字段(registry-mirrors、builder、features)一律保留,改前先备份:
bash
cp daemon.json daemon.json.bak-20260101
然后重启 Docker:
bash
docker desktop restart # 或右键托盘 Quit 后重新启动
验证
bash
docker network inspect bridge --format '{{.IPAM.Config}}'
# 输出: [{172.31.0.0/16 ...}] ← 已改
docker exec docker-api-1 curl -s http://172.17.16.52:11434/api/version
# {"version":"0.40.15"} ← 通了
为什么改 Docker,而不是改机器 IP
改 B 机 IP 会牵动:路由、共享目录、你其他设备访问 Dify 的地址、可能的 DHCP 绑定。而改 Docker 网段只影响这台机上的容器网络,面小得多。
副作用 :容器 IP 会变成
172.31.x.x/172.30.x.x。Dify 的 compose 里服务之间走 Docker DNS(postgres、redis、api这样的名字),没有硬编码 IP,所以业务无感。 回滚 :备份文件覆盖回去 +docker desktop restart,冲突立刻复现。
通俗版
小区叫"17 号楼",门牌是 17-16-52。Docker 装进来时自作主张,也给自己划了一片"17 号楼",并立了块牌子:"17 号楼的事我自己处理。"
Dify 派人去 17-16-52 送信,走到门口一看------"哦,17 号楼,自己人",信就交内部了,根本没送出小区。
所以:信发出去了,10 秒没到,报超时。用 WorkBuddy(小区里的普通住户)送,一送就到,因为它不归 Docker 管;连"给隔壁 17-16-46 送"也失败,因为同一个门牌体系下,谁都被扣。
改 bip 就是让 Docker 换个门牌段(31 号),门卫不再误会。
二、坑二:模型加上了,一轮要两分半 ------ 思考 token 吃掉了输出预算
现象
添加成功后,卡片显示 qwen-api LLM CHAT 16K,对话能出字,但单轮耗时 2 分 47 秒。Dify 的节点明细非常说明问题:
| 节点 | 耗时 | token |
|---|---|---|
| 用户输入 | 28 ms | --- |
| LLM | 167 s | 623 |
| 直接回复 | 2 ms | --- |
623 个 token,换来的正文只有二十几个字。 多出来的 token 去哪了?去了"思考"。
Qwen3.5 是带思考链的模型,默认先想一大段再答。而这段思考是算在 num_predict / max_tokens 预算里的 ------ 预算 2048,思考烧掉 600 多,正文只剩一点。所以表现是"又慢又短"。
而且 Dify 的 Ollama 插件不会关它
插件(langgenius/ollama:1.0.3)调用时,请求体里根本没有"是否思考"这个字段:
json
{
"model": "qwen-api",
"messages": [...],
"stream": true
}
它既不要求思考,也不拒绝思考 ------ 它只是没提。而 Ollama 的默认行为是:想。
四条走不通的路(都实测过,省你再试)
| 尝试 | 结果 |
|---|---|
Modelfile 里加 PARAMETER think false |
0.40.15 报 Error: unknown parameter 'think' |
| Dify 供应商表单里找"思考模式"开关 | Ollama 凭据、OpenAI 兼容供应商,字段逐项读完,都没有这一项 |
OpenAI 兼容接口传 think: false |
被静默忽略(不是报错,是当没看见) |
| 人设里写"不要思考,直接回答" | 无效 ------ 这是提示词,压不住模板层的思考开头 |
关键认知:think 是服务端开关,不是给模型的话
这一点想通了,后面都顺:
think: true(默认)→ Ollama 拼对话模板时,插入"请先思考"的开头,模型看到才开始打草稿。think: false→ 服务端不插这段。模型从头到尾没看到"先思考",自然直接答。
所以它不是"说服模型",而是"让那句要求一开始就不存在"。这也解释了为什么"换个语气更硬的提示词"永远不管用。
同理,走 OpenAI 兼容接口时对应的字段是
reasoning_effort: "none"。两个 API 两个名字,放错位置就是静默忽略。
两头都改不动,那就改中间
- 模型侧:Modelfile 不认这个参数
- Dify 侧:插件代码和界面都没有这一项
只剩一条路:在两者之间插一层,补上这个字段。
arduino
Dify 容器 ──► 172.17.16.52:11435(薄转发,补 think:false)──► 127.0.0.1:11434(Ollama)
核心逻辑其实只有几十行(纯标准库,无依赖):
python
# 只做一件事:把请求体读出来,补字段,再原样转发
THINK_PATHS = ("/api/chat", "/api/generate") # 原生接口
REASON_PATHS = ("/v1/chat/completions",) # OpenAI 兼容
def rewrite(path, body):
data = json.loads(body)
if path in THINK_PATHS:
data["think"] = False # ← 关键一行
elif path in REASON_PATHS:
data["reasoning_effort"] = "none" # ← 关键一行
return json.dumps(data).encode()
# 其余路径(/api/tags、/api/version、/api/ps)原样透传
# 流式响应按块转发、不缓冲,否则会破坏 Dify 的逐字输出
Dify 侧只改一个字段 :凭据里的基础 URL,11434 → 11435。 模型名、上下文 16384、最大 token 2048、Vision、函数调用 ------ 一个都没动。
实测对比
| 改造前 | 改造后 | |
|---|---|---|
| LLM token | 623 | 73 |
| LLM 耗时 | 167 s | 9.96 s |
| 正文 | 二十几字(挤出来的) | 正常 |
| 多轮记忆 | 正常 | 正常(第二轮能正确指代上文) |
注意:生成速度前后基本一样(7~8 tok/s),慢的不是"算得慢",是"它多算了 500 多个 token"。关掉思考,时间就回到应有的量级。
工程化三件事
- 无窗口常驻 :用
pythonw.exe起脚本(python.exe会弹黑窗),配 Windows 计划任务,AtLogOn触发。 - 日志 :
pythonw没有 stderr,脚本必须加--log-file,否则出问题什么都看不到。日志里能看到rewrote /api/chat -> think=false才算真的生效。 - 防火墙 :新监听端口要单独加入站规则(
11435 / Inbound / Allow)。
通俗版
这个模型是个爱打草稿的学生。你问"什么是局域网",它先密密麻麻写两百多字的思考,再腾地方写答案。可你只给了 2048 格的纸 ------ 草稿写掉 600 多格,答案只剩一点。于是你等了两分半,看到二十几个字。
而 Dify 递卷子时,从不替你说"别打草稿,直接答"。
中间人做的事就是:进门时替 Dify 补一句"别打草稿"。模型这回答得干脆,73 格就写完了。
注意:它不是"听了中间人的话",是中间人帮它把 Dify 漏填的那一格勾上了。
三、顺带一个坑:模型名该填别名,别填底座 tag
Dify 的"模型名称"一栏,填 qwen3.5:4b-q4_K_M 也能跑起来 ------ 看起来没错,因为那确实是本地模型。
但如果这个项目里已经有一个叫 qwen-api 的 Modelfile 别名,填底座 tag 就等于绕过了它。别名里通常固化了:
sql
PARAMETER num_ctx 16384
PARAMETER num_predict 2048
PARAMETER temperature 0.7
PARAMETER top_p 0.9
PARAMETER repeat_penalty 1.05
SYSTEM "你是运行在这台 Windows 电脑上的本地大模型助手。回答简洁、准确......"
填 tag = 用默认参数 + 没有你的人设;填别名 = 用调好的那套。同一个模型,两种性格。
经验:接入第三方客户端时,优先复用已有的别名,而不是"看起来更正确"的底座 tag。名字对不上的时候,先去看项目里有没有现成的约定。
四、复盘:把问题分层,比记住结论更重要
排查这类问题,最贵的不是技术细节,是在错误的层里反复试。这次的两个根因,本质是两类问题:
| 层 | 症状 | 该查什么 |
|---|---|---|
| 通不通(网络) | 添加时报错、超时、refused | 监听地址、防火墙、容器侧路由 |
| 快不快(模型参数) | 出字正常但极慢 / 正文为空 | 思考模式、token 预算、上下文 |
| 对不对(配置语义) | 答得怪、没记住上文 | 模型名(别名 vs tag)、上下文长度、人设 |
三条判别式,可以直接抄走:
- 同一目标,宿主进程通、容器进程不通 → 几乎一定是容器网络问题,别再去查防火墙了。
- 容器连"同网段的另一台机"也超时 → 问题在网段,不在目标主机。
- 耗时集中在 LLM 节点、token 数远大于正文长度 → 思考模式没关,别去调
num_ctx或换模型。
用一句话概括这两件事:
"加不上"是地址撞车(信送不出小区);"加上了很慢"是模型爱打草稿(你和它之间没人喊停)。 两个完全独立的问题,各修一次,才变成最后能用、也快的样子。
五、可直接复制的配置清单
① Docker 网段(B 机 daemon.json)
json
{
"bip": "172.31.0.1/16",
"default-address-pools": [
{ "base": "172.30.0.0/16", "size": 24 }
]
}
② 请求参数注入规则
| 请求路径 | 注入 |
|---|---|
/api/chat、/api/generate |
"think": false |
/v1/chat/completions |
"reasoning_effort": "none" |
| 其他 | 原样透传 |
③ 三条验证命令
bash
# 容器能到模型服务
docker exec docker-api-1 curl -s http://<A机IP>:11435/api/version
# 网桥网段已改
docker network inspect bridge --format '{{.IPAM.Config}}'
# 转发层确实在改请求(看日志里有没有 rewrote 记录)
tail -f proxy.log
六、补一个对照:同一台 Ollama,WorkBuddy 不思考、Dify 思考
同一个模型 qwen-api,同一台 Ollama,两个客户端表现完全相反:
- WorkBuddy:问一句,输出是干净正文,544 token,没有思考段;
- Dify:同样的模型,623 token、两分半,思考把预算吃光。
差别不在模型,也不在服务端,只在请求体里有没有那一个字段。
| 客户端 | 请求里带不带"别思考" | 结果 |
|---|---|---|
| WorkBuddy("思考模式"不勾选) | 带(走 /v1 是 reasoning_effort: "none") |
正文干净,544 token |
| Dify Ollama 插件 1.0.3 | 带不了(表单里没有这一项,插件代码里也没有) | 默认思考,623 token / 167 s |
WorkBuddy 的「添加模型」弹窗里有一排勾选框:工具调用 / 图片输入 / 思考模式 / 自定义协议。思考模式不勾,它在发给模型的请求里就不要求思考------模型压根没收到"先打草稿"这句。而 Dify 那张表单上根本没有这个格子,它想填也填不了。
一句话:一个带了参数,一个带不了参数。
所以 11435 那层转发做的事,本质就是替 Dify 把它填不了的字段补上。
两个容易混的细节:
- "看不到思考"有两种含义。 一种是"没要"(WorkBuddy 现在这种,请求里就没有这个要求);另一种是"要了但客户端不渲染"(服务端其实想了、token 也烧了,只是返回的
reasoning_content界面没画出来)。区分办法是看生成量:不带思考几十到几百 token 就结束,带思考会一路顶到num_predict上限(2048)。 - 不勾 ≠ 禁止,只是不要求。 极少数情况下答案里会带一点推理碎片,但不会再单开一整段思考,也不会把 2048 烧穿。
通俗版:WorkBuddy 是进门时会开口说"别打草稿"的那位;Dify 是哑巴,进门时什么也不说。门卫(Ollama)按默认规矩假定"先打草稿再说"。中间人守在门口,替哑巴把这句话补上。
结语
这两个坑有一个共同点:报错信息都在骗你。
connect timeout 让你去查防火墙和端口,实际是路由;"回答很慢"让你去调参和换模型,实际是请求里少一个字段。
所以排查的顺序应该是:先问"属于哪一层",再看"这一层的证据是什么"。 一旦跨层猜,时间就全花在验证错误假设上了。
而最省事的解法往往不在两端 ------ 当两头都改不动、问题又只是"请求里少了个字段"时,在中间插一层改请求,比改任何一端都便宜。
文中地址保留为会冲突的
172.17.16.x段,便于复现;实际部署请替换成自己的网段。