Dify 连局域网 Ollama 的两个坑:docker0 网段撞车,和请求里少掉的那个参数

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"。关掉思考,时间就回到应有的量级。

工程化三件事

  1. 无窗口常驻 :用 pythonw.exe 起脚本(python.exe 会弹黑窗),配 Windows 计划任务,AtLogOn 触发。
  2. 日志 :pythonw 没有 stderr,脚本必须加 --log-file,否则出问题什么都看不到。日志里能看到 rewrote /api/chat -> think=false 才算真的生效。
  3. 防火墙 :新监听端口要单独加入站规则(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)、上下文长度、人设

三条判别式,可以直接抄走:

  1. 同一目标,宿主进程通、容器进程不通 → 几乎一定是容器网络问题,别再去查防火墙了。
  2. 容器连"同网段的另一台机"也超时 → 问题在网段,不在目标主机。
  3. 耗时集中在 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 把它填不了的字段补上。

两个容易混的细节:

  1. "看不到思考"有两种含义。 一种是"没要"(WorkBuddy 现在这种,请求里就没有这个要求);另一种是"要了但客户端不渲染"(服务端其实想了、token 也烧了,只是返回的 reasoning_content 界面没画出来)。区分办法是看生成量:不带思考几十到几百 token 就结束,带思考会一路顶到 num_predict 上限(2048)。
  2. 不勾 ≠ 禁止,只是不要求。 极少数情况下答案里会带一点推理碎片,但不会再单开一整段思考,也不会把 2048 烧穿。

通俗版:WorkBuddy 是进门时会开口说"别打草稿"的那位;Dify 是哑巴,进门时什么也不说。门卫(Ollama)按默认规矩假定"先打草稿再说"。中间人守在门口,替哑巴把这句话补上。


结语

这两个坑有一个共同点:报错信息都在骗你。

connect timeout 让你去查防火墙和端口,实际是路由;"回答很慢"让你去调参和换模型,实际是请求里少一个字段。

所以排查的顺序应该是:先问"属于哪一层",再看"这一层的证据是什么"。 一旦跨层猜,时间就全花在验证错误假设上了。

而最省事的解法往往不在两端 ------ 当两头都改不动、问题又只是"请求里少了个字段"时,在中间插一层改请求,比改任何一端都便宜。

文中地址保留为会冲突的 172.17.16.x 段,便于复现;实际部署请替换成自己的网段。

相关推荐
MocMoc29 分钟前
从 0 到 1:把 Dify Chatflow 接进一个 qiankun 中后台
人工智能
民乐团扒谱机1 小时前
【微实验】NSGA-II(非支配排序遗传算法)——解决多目标优化问题的“自然选择模拟器”
linux·人工智能·算法
怎么全是重名1 小时前
VerdictAD
人工智能·异常检测·few-shot·zsas·异常分割
Bode_20022 小时前
具身智能机构设计的质量控制
人工智能
阿里云大数据AI技术2 小时前
EMR Serverless StarRocks × Lance:多模态分析与检索的云上实践
人工智能
SelectDB2 小时前
数据不能出境的数仓选型与迁移落地:四问框架、可复算成本与迁移三段式
人工智能
李青菜2 小时前
一个人 + AI,用 Tauri 2 和 Rust 写了个开源的 Windows 效率工具:提醒 + 笔记 + 启动器
人工智能
泯泷2 小时前
做 AI 产品,路线图该数“实验”,而不是数“功能”
人工智能·llm·产品