用 Dify 做合同知识库:Qwen 对话、bge 重排和 Langfuse 工具追踪
目录
服务器内网:10.0.1.47 Dify 目录:/usr/cluster/dify New-API 的安装和令牌案例仍在 new-api/在 vLLM 前面加 New-API:渠道、令牌额度和邀请奖励.md,那份文件没有改。监控安装在 observability/Langfuse 加 DCGM:会话追踪和 GPU 显存、温度、利用率.md。客服、工单、监控、自愈和工作流写在同级目录 场景。
本文对应的程序:
| 文件 | 作用 |
|---|---|
scripts/01-部署Dify.sh | 拉取 Dify 1.17.1 并用 pgvector 启动 |
scripts/02-启动Xinference.sh | 在 GPU 上启动向量服务 |
scripts/03-接入对话和向量模型.py | 对话走 New-API,向量走 Xinference |
scripts/04-启用Rerank.sh | 加载重排模型并设为 Dify 默认 |
scripts/05-合同知识库.py | 「阿里云合同条款」知识库和 Retrieval 1 的请求体 |
scripts/06-打开知识检索开关.py | 让配置页显示出「知识检索」 |
scripts/07-放行工具访问内网.sh | 允许工具经代理访问内网状态接口 |
scripts/08-注册服务器状态工具.py | 注册 getServerStatus 并挂到运维巡检 Agent |
scripts/09-补齐Langfuse工具Span.py | 把工具调用写成 Langfuse 的 Span |
server-status/app.py | 模拟服务器状态接口,端口 8777 |
server-status/server-status.service | 状态接口的开机服务 |
config/server-status-openapi.yaml | OpenAPI,operationId 是 getServerStatus |
config/dify-env-要点.txt | 现场核对过的环境项 |
脚本已经在现网执行过。不要为了对照文档再跑一遍 01、02,那会碰到容器已存在。07 会重建两个代理容器。08 发现同名工具和 Agent 已在时只打印编号。09 会重启 dify-worker-1。Windows 上传后如果报 $'\r',先执行 sed -i 's/\r$//' 脚本名。不要把本文发到公网。
一、安装配置
1. 目标
在已经运行的 New-API 和 vLLM 旁边加一层 Dify:
浏览器
→ Dify(知识库 / 工作流 / Agent)
→ 对话:New-API(鉴权、额度)→ vLLM qwen3.6-27b → GPU
→ 向量和重排:Xinference(bge-m3、bge-reranker-v2-m3)→ GPU 0第一次安装时,向量和重排先放在 CPU 上,没有重启 vLLM 和 New-API。后来把 vLLM 的 --gpu-memory-utilization 从 0.75 降到 0.70,再把这两个模型放到同一张 H20 上。上下文长度仍是 65536。New-API 没有重启。
动手前已经在跑的服务:
| 服务 | 容器 | 地址 |
|---|---|---|
| vLLM | vllm-qwen3.6-27b | http://10.0.1.47:8000/v1 |
| New-API | new-api | http://10.0.1.47:3000 |
| 主机 Nginx | 占用 80 端口 | 不要动 |
| Kubernetes | kubelet、calico、coredns | 不要动 |
Docker 数据盘在 /usr/cluster。根分区空间小,镜像和体积大的数据都放 /usr/cluster。
容器之间访问宿主机端口用 Docker 网桥 172.17.0.1。从容器里访问公网地址再绕回本机会卡住。
2. 部署 Dify
版本是 Dify 1.17.1(2026-09-10 的 tag)。官方 compose 用稀疏克隆,只取 docker 目录,因为直接下 raw 文件会中断。
Weaviate 镜像拉不下来,向量库改成 pgvector。主机 80 被占用,页面改到 8088,HTTPS 改到 18443。
文件:scripts/01-部署Dify.sh
#!/bin/bash
# 已在 10.0.1.47 执行。不要拿它去重建正在跑的 Dify,除非容器已经不在。
# 不启动、不重启 vLLM 和 New-API。
set -eu
ROOT=/usr/cluster/dify
SRC="$ROOT/src"
cd "$ROOT"
if [ ! -d "$SRC/docker" ]; then
git clone --depth 1 --filter=blob:none --sparse --branch 1.17.1 \
https://github.com/langgenius/dify.git "$SRC"
git -C "$SRC" sparse-checkout set docker
fi
cd "$SRC/docker"
cp -n .env.example .env
# 主机 80 端口已被占用,Dify 改到 8088。向量库用 pgvector,不拉 weaviate。
sed -i \
-e 's/^EXPOSE_NGINX_PORT=.*/EXPOSE_NGINX_PORT=8088/' \
-e 's/^EXPOSE_NGINX_SSL_PORT=.*/EXPOSE_NGINX_SSL_PORT=18443/' \
-e 's/^VECTOR_STORE=.*/VECTOR_STORE=pgvector/' \
-e 's/^CELERY_WORKER_AMOUNT=.*/CELERY_WORKER_AMOUNT=2/' \
-e 's/^COMPOSE_PROJECT_NAME=.*/COMPOSE_PROJECT_NAME=dify/' \
.env
grep -q '^NEXT_PUBLIC_ENABLE_AGENT_V2=' .env \
&& sed -i 's/^NEXT_PUBLIC_ENABLE_AGENT_V2=.*/NEXT_PUBLIC_ENABLE_AGENT_V2=true/' .env \
|| echo 'NEXT_PUBLIC_ENABLE_AGENT_V2=true' >> .env
# 官方镜像名。现场是先从 docker.m.daocloud.io 拉取再打回这些标签。
docker compose up -d
docker compose ps现场核对过的环境项在 config/dify-env-要点.txt:
COMPOSE_PROJECT_NAME=dify
VECTOR_STORE=pgvector
DB_TYPE=postgresql
COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql},collaboration
EXPOSE_NGINX_PORT=8088
EXPOSE_NGINX_SSL_PORT=18443
CELERY_WORKER_AMOUNT=2
NEXT_PUBLIC_ENABLE_AGENT_V2=true
PLUGIN_DEBUGGING_PORT=5003
NGINX_CLIENT_MAX_BODY_SIZE=100M
SSRF_PROXY_ALLOW_PRIVATE_IPS=172.17.0.0/16,10.0.0.0/8SSRF_PROXY_ALLOW_PRIVATE_IPS 现为 172.17.0.0/16,10.0.0.0/8。对话和向量请求仍由 Dify API 容器直接访问 172.17.0.1。工具请求要经过 Squid,原先空着时访问内网会 403。放行后重建过 dify-ssrf_proxy-1 和 dify-agent_ssrf_proxy-1,见第 5 节。没有重建 dify-web-1。
应用库、向量库的口令是官方示例值 difyai123456,用户 postgres,库名 dify。
镜像:
| 容器 | 镜像 |
|---|---|
| dify-api / worker / websocket | langgenius/dify-api:1.17.1 |
| dify-web | langgenius/dify-web:1.17.1 |
| dify-agent_backend | langgenius/dify-agent-backend:1.17.1 |
| dify-sandbox | langgenius/dify-sandbox:0.2.15 |
| dify-local_sandbox | langgenius/dify-agent-local-sandbox:1.17.1 |
| dify-plugin_daemon | langgenius/dify-plugin-daemon:0.6.10-local |
| dify-db_postgres | postgres:15-alpine |
| dify-pgvector | pgvector/pgvector:pg16 |
| dify-redis | redis:6-alpine |
| dify-nginx | nginx:latest |
| ssrf 代理 | ubuntu/squid:latest |
页面:http://10.0.1.47:8088 管理员:admin@example.com / Dify-rag-2026
初始化接口 POST /console/api/setup。邮箱必须带点,admin@local 会被拒绝。密码至少 8 位,并且同时有字母和数字。第二次初始化返回 403,表示已经完成。
控制台接口登录时,密码要做 Base64,不能发明文。请求还要同时带上 Cookie 和 X-CSRF-Token,两者都来自登录响应的 Set-Cookie。
POST /console/api/login
{"email":"admin@example.com","password":"<Dify-rag-2026 的 Base64>","language":"zh-Hans","remember_me":true}3. 启动 Xinference
文件:scripts/02-启动Xinference.sh
#!/bin/bash
# 已在 10.0.1.47 执行。向量和重排都在 GPU 0 上。
# 容器如果已经存在,不要再 docker run 一次。
# docker rm 之后重建,需要再装一次 sentence-transformers,并重新加载两个模型。
set -eu
mkdir -p /usr/cluster/dify/xinference
docker run -d \
--name xinference-bge \
--restart unless-stopped \
--gpus all \
--cpus 4 \
--memory 12g \
--memory-swap 12g \
-p 9997:9997 \
-e XINFERENCE_HOME=/data \
-e XINFERENCE_MODEL_SRC=modelscope \
-v /usr/cluster/dify/xinference:/data \
xprobe/xinference:latest \
xinference-local -H 0.0.0.0 --log-level info
docker exec xinference-bge pip install -i https://pypi.tuna.tsinghua.edu.cn/simple sentence-transformers这个镜像第一次启动要先做管理员初始化,用户名 admin,密码 Xinf-bge-2026。加载模型时 n_gpu 写 1,两个模型都在 GPU 0 上。最早那一版用 NVIDIA_VISIBLE_DEVICES=void 把模型留在 CPU,已经换成现在这条命令。
向量模型:
POST /v1/models?wait_ready=true
{
"model_name": "bge-m3",
"model_type": "embedding",
"n_gpu": 1,
"download_hub": "modelscope"
}测过一次嵌入,维度 1024。页面:http://10.0.1.47:9997
sentence-transformers 装在当前容器里。docker restart 还在;如果 docker rm 之后重建容器,需要再装一次。
4. 接入对话模型和向量模型
New-API 里给 Dify 单独建了一把令牌,名字 dify-gateway,编号 3,属于用户 admin。密钥以 sk-C0P9 开头。完整密钥在 New-API 的 tokens 表和 Dify 的模型凭证里,列表接口只显示掩码。不要再创建同名令牌。
这把令牌写文档时的状态:
| 项 | 值 |
|---|---|
| 状态 | 启用 |
| 额度 | 无限 |
| 已用 | 约 0.36 美元 |
| 过期时间 | -1,永不过期 |
| 模型限制 | 只允许 qwen3.6-27b |
| 实际扣费账户 | admin,剩余约 181.08 美元 |
额度换算:500000 额度 = 1 美元。
Dify 工作区默认模型:
| 类型 | 供应商 | 模型 | 地址 |
|---|---|---|---|
| 对话 | langgenius/openai_api_compatible/openai_api_compatible | qwen3.6-27b | http://172.17.0.1:3000/v1 |
| 向量 | langgenius/xinference/xinference | bge-m3 | http://172.17.0.1:9997 |
| 重排 | langgenius/xinference/xinference | bge-reranker-v2-m3 | http://172.17.0.1:9997 |
对话模型上下文 65536,单次输出上限 8192,函数调用类型 tool_call。经 New-API 打过一次对话,HTTP 200。
文件:scripts/03-接入对话和向量模型.py
#!/usr/bin/env python3
"""记录已经完成的模型注册。知识库向量走 Xinference,对话走 New-API 的 dify-gateway。
重复执行会碰到「模型已存在」。看到 Can't add same credential 可以停。
不要再创建第二把名为 dify-gateway 的令牌。
"""
import json
import os
import urllib.request
DIFY = os.environ.get("DIFY", "http://127.0.0.1:8088")
XINFERENCE = "http://172.17.0.1:9997"
NEW_API = "http://172.17.0.1:3000/v1"
GATEWAY_KEY = os.environ["DIFY_GATEWAY_KEY"]
def post(url, body, headers):
request = urllib.request.Request(url, data=json.dumps(body).encode(), method="POST")
request.add_header("Content-Type", "application/json")
for key, value in headers.items():
request.add_header(key, value)
with urllib.request.urlopen(request, timeout=60) as resp:
return resp.status
llm = {
"model": "qwen3.6-27b",
"model_type": "llm",
"credentials": {
"mode": "chat",
"context_size": "65536",
"max_tokens_to_sample": "8192",
"function_calling_type": "tool_call",
"stream_function_calling": "supported",
"display_name": "qwen3.6-27b",
"endpoint_url": NEW_API,
"api_key": GATEWAY_KEY,
},
}
embedding = {
"model": "bge-m3",
"model_type": "text-embedding",
"credentials": {
"server_url": XINFERENCE,
"model_uid": "bge-m3",
},
}保存接口:
POST /console/api/workspaces/current/model-providers/<provider>/models/credentials
POST /console/api/workspaces/current/default-model供应商路径:
langgenius/openai_api_compatible/openai_api_compatiblelanggenius/xinference/xinference
插件安装时如果返回 401,补上 Cookie 和 X-CSRF-Token 再提交。凭证已经生效后再点一次,会返回 Can't add same credential,这是正常的。
5. 放行工具访问内网
getServerStatus 的地址是 http://172.17.0.1:8777。Dify 的工具请求经过 Squid。原先 SSRF_PROXY_ALLOW_PRIVATE_IPS 为空,代理返回 403。现已写成 172.17.0.0/16,10.0.0.0/8,并只重建了 dify-ssrf_proxy-1 和 dify-agent_ssrf_proxy-1。
文件:scripts/07-放行工具访问内网.sh
#!/bin/bash
# 已在 10.0.1.47 执行。工具请求经 Squid 访问 http://172.17.0.1:8777。
# 再跑一次只会改这一行环境变量,并重建两个代理容器。
# 不重建 dify-web-1、api、vLLM、New-API。
set -eu
python3 - <<'PY'
from pathlib import Path
path = Path("/usr/cluster/dify/src/docker/.env")
lines = path.read_text(encoding="utf-8").splitlines()
for index, line in enumerate(lines):
if line.startswith("SSRF_PROXY_ALLOW_PRIVATE_IPS="):
lines[index] = "SSRF_PROXY_ALLOW_PRIVATE_IPS=172.17.0.0/16,10.0.0.0/8"
break
else:
raise SystemExit("找不到 SSRF_PROXY_ALLOW_PRIVATE_IPS")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
print("SSRF_PROXY_ALLOW_PRIVATE_IPS=172.17.0.0/16,10.0.0.0/8")
PY
cd /usr/cluster/dify/src/docker
docker compose -p dify up -d --force-recreate --no-deps ssrf_proxy agent_ssrf_proxy放行后,从 dify-api-1 经 ssrf_proxy:3128 和 agent_ssrf_proxy:3128 访问 http://172.17.0.1:8777/server/status?server_name=gpu-server-01,都返回 CPU 45%、内存 62%、GPU 利用率 78%、显存 18.2/24 GB。
6. 服务器状态接口
接口用系统自带的 Python 起在宿主机 0.0.0.0:8777,开机服务名 server-status。程序在 /usr/cluster/server-status/app.py。operationId 使用 getServerStatus,这就是 Langfuse 里 Observation 的名字。
文件:server-status/app.py
#!/usr/bin/env python3
"""作业 1 的模拟服务器状态接口。operationId 对应 Langfuse 里的 getServerStatus。"""
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/health":
self._send({"status": "ok"})
return
if parsed.path == "/server/status":
name = (parse_qs(parsed.query).get("server_name") or ["unknown"])[0]
self._send(
{
"server": name,
"cpu": "45%",
"memory": "62%",
"gpu_util": "78%",
"gpu_memory": "18.2/24 GB",
}
)
return
self.send_response(404)
self.end_headers()
def _send(self, payload):
body = json.dumps(payload, ensure_ascii=False).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
print("%s - %s" % (self.address_string(), fmt % args), flush=True)
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", 8777), Handler).serve_forever()文件:server-status/server-status.service
[Unit]
Description=Homework server status API
After=network.target
[Service]
ExecStart=/usr/bin/python3 /usr/cluster/server-status/app.py
WorkingDirectory=/usr/cluster/server-status
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target文件:config/server-status-openapi.yaml
openapi: 3.0.0
info:
title: Server Status API
version: 1.0.0
description: 模拟查询服务器 CPU、内存、GPU 状态
servers:
- url: http://172.17.0.1:8777
paths:
/server/status:
get:
operationId: getServerStatus
summary: 查询服务器状态
description: 根据服务器名称返回 CPU、内存、GPU 利用率等模拟指标
parameters:
- name: server_name
in: query
required: true
description: 服务器名称,例如 gpu-server-01
schema:
type: string
responses:
"200":
description: 成功
content:
application/json:
schema:
type: object
properties:
server:
type: string
cpu:
type: string
memory:
type: string
gpu_util:
type: string
gpu_memory:
type: string服务已经是 active。健康检查 http://127.0.0.1:8777/health 返回 {"status":"ok"}。
7. 注册工具,并把调用写进 Langfuse
工具提供方名称 server_status,编号 a69388a5-c02f-4bf1-8c28-1f20ee0687e6。Agent 名称「运维巡检 Agent」。
| 项 | 值 |
|---|---|
| agent_id | 01a124c7-9fce-70a8-9efa-41b2a6f5b7e7 |
| app_id | e4d614f6-de22-4f4c-b58e-caeb0504b121 |
| 模型 | qwen3.6-27b,供应商 langgenius/openai_api_compatible/openai_api_compatible |
| Langfuse 页面 | http://10.0.1.47:3100 |
| 登录 | admin@example.com / Lf-trace-2026 |
| 项目 | 会话追踪 |
| 公钥 | pk-lf-d7a2e5c771eb542a |
| 容器内上报地址 | http://172.17.0.1:3100 |
项目私钥只在服务器 /usr/cluster/observability/langfuse/.env 的 LANGFUSE_INIT_PROJECT_SECRET_KEY。脚本读取这一项,不把它写进本文。
文件:scripts/08-注册服务器状态工具.py
#!/usr/bin/env python3
"""注册 getServerStatus,并挂到「运维巡检 Agent」。
现网已经执行过。再跑时,同名工具和同名 Agent 已存在就只打印编号,不删除、不重建。
在 10.0.1.47 上执行。Windows 上传后先去掉 CR:sed -i 's/\r$//' 本文件。
"""
from __future__ import annotations
import base64
import json
from http.cookiejar import CookieJar
from pathlib import Path
from urllib import request
BASE = "http://127.0.0.1:8088/console/api"
PROVIDER = "server_status"
TOOL_NAME = "getServerStatus"
APP_NAME = "运维巡检 Agent"
AGENT_ID = "01a124c7-9fce-70a8-9efa-41b2a6f5b7e7"
APP_ID = "e4d614f6-de22-4f4c-b58e-caeb0504b121"
PROVIDER_ID = "a69388a5-c02f-4bf1-8c28-1f20ee0687e6"
SCHEMA_PATH = Path(__file__).resolve().parents[1] / "config" / "server-status-openapi.yaml"
SYSTEM_PROMPT = """你是企业内部运维助手。当用户询问某台服务器的状态、CPU、内存、GPU 时,
必须调用工具 getServerStatus(参数 server_name),并根据工具返回结果用中文格式化汇报。
汇报格式示例:
- 服务器:{server}
- CPU:{cpu}
- 内存:{memory}
- GPU 利用率:{gpu_util}
- GPU 显存:{gpu_memory}
不要编造指标;工具失败时如实说明。"""
ADD_BODY = {
"provider": PROVIDER,
"credentials": {"auth_type": "none"},
"schema_type": "openapi",
"schema": SCHEMA_PATH.read_text(encoding="utf-8"),
"icon": {"background": "#E8F5E9", "content": "S"},
"privacy_policy": "",
"custom_disclaimer": "",
"labels": [],
}
CREATE_AGENT_BODY = {
"name": APP_NAME,
"description": "带 HTTP 工具的运维巡检 Agent,查询服务器状态",
"icon_type": "emoji",
"icon": "🖥️",
"icon_background": "#E8F5E9",
"role": "ops",
}
TOOL_ENTRY = {
"enabled": True,
"provider_type": "api",
"provider_id": PROVIDER_ID,
"tool_name": TOOL_NAME,
"credential_type": "unauthorized",
"credential_ref": None,
"runtime_parameters": {},
}
class Dify:
def __init__(self) -> None:
self.jar = CookieJar()
self.opener = request.build_opener(request.HTTPCookieProcessor(self.jar))
self.csrf = ""
def login(self) -> None:
self.call(
"POST",
"/login",
{
"email": "admin@example.com",
"password": base64.b64encode(b"Dify-rag-2026").decode(),
"language": "zh-Hans",
"remember_me": True,
},
)
for cookie in self.jar:
if cookie.name == "csrf_token":
self.csrf = cookie.value
if not self.csrf:
raise SystemExit("没有拿到 csrf_token")
def call(self, method: str, path: str, payload: dict | None = None) -> tuple[int, dict | list]:
data = None if payload is None else json.dumps(payload).encode()
req = request.Request(BASE + path, data=data, method=method)
if payload is not None:
req.add_header("Content-Type", "application/json")
if self.csrf:
req.add_header("X-CSRF-Token", self.csrf)
with self.opener.open(req, timeout=60) as resp:
raw = resp.read().decode()
body = json.loads(raw) if raw else {}
return resp.status, body
def enable_trace(dify: Dify, app_id: str) -> None:
env: dict[str, str] = {}
for line in open("/usr/cluster/observability/langfuse/.env", encoding="utf-8"):
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
env[key] = value.strip().strip('"')
dify.call(
"POST",
"/apps/%s/trace-config" % app_id,
{
"tracing_provider": "langfuse",
"tracing_config": {
"public_key": env["LANGFUSE_INIT_PROJECT_PUBLIC_KEY"],
"secret_key": env["LANGFUSE_INIT_PROJECT_SECRET_KEY"],
"host": "http://172.17.0.1:3100",
},
},
)
dify.call(
"POST",
"/apps/%s/trace" % app_id,
{"enabled": True, "tracing_provider": "langfuse"},
)
print("追踪已打开", app_id)
def main() -> None:
dify = Dify()
dify.login()
_status, tools = dify.call("GET", "/workspaces/current/tools/api")
names = [(item.get("name") or item.get("provider")) for item in tools]
if PROVIDER in names:
print("工具已在", PROVIDER, PROVIDER_ID)
else:
status, _created = dify.call("POST", "/workspaces/current/tool-provider/api/add", ADD_BODY)
print("已注册", PROVIDER, status)
_status, listed = dify.call("GET", "/agent?page=1&limit=50")
found = None
for item in listed.get("data") or []:
if item.get("name") == APP_NAME:
found = item
break
if found:
print("Agent 已在", found.get("agent_id") or found.get("id"), "app", APP_ID)
enable_trace(dify, APP_ID)
return
status, created = dify.call("POST", "/agent", CREATE_AGENT_BODY)
agent_id = created.get("agent_id") or created.get("id")
print("已创建", status, agent_id)
_status, state = dify.call("GET", "/agent/%s/composer" % agent_id)
soul = state["agent_soul"]
soul.setdefault("prompt", {})["system_prompt"] = SYSTEM_PROMPT
soul.setdefault("tools", {})["dify_tools"] = [TOOL_ENTRY]
soul.setdefault("tools", {})["cli_tools"] = soul["tools"].get("cli_tools") or []
model = soul.get("model") if isinstance(soul.get("model"), dict) else {}
model.update(
{
"credential_ref": None,
"model": "qwen3.6-27b",
"model_provider": "langgenius/openai_api_compatible/openai_api_compatible",
"plugin_id": "langgenius/openai_api_compatible",
}
)
soul["model"] = model
dify.call(
"PUT",
"/agent/%s/composer" % agent_id,
{
"variant": "agent_app",
"save_strategy": "save_to_current_version",
"agent_soul": soul,
"version_note": "挂上 getServerStatus",
},
)
dify.call("POST", "/agent/%s/publish" % agent_id, {"version_note": "挂上 getServerStatus"})
print("已发布", agent_id)
enable_trace(dify, state.get("app_id") or state.get("backing_app_id") or APP_ID)
if __name__ == "__main__":
main()Dify 1.17 的 Agent 会把工具名写进 message_agent_thoughts.tool。Langfuse v4 要求 Span 的 id 是字符串。原文件这两处会让工具调用进不了 Observations。补丁做了两件事:给 generation 和 span 补上 id;从一条新的数据库会话读出工具的输入和输出,写成名为工具名的 Span,并挂到这次 llm 下面。
补丁在容器内文件:
/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py
dify-api-1 和 dify-worker-1 都有这份文件。真正执行上报的是 worker。按镜像重建这两个容器后补丁会丢,再跑下面的脚本。不要为了打补丁去重建 dify-web-1。
文件:scripts/09-补齐Langfuse工具Span.py
#!/usr/bin/env python3
"""给 Dify 的 Langfuse 上报补上工具 Span。
现网的 dify-worker-1 已经打过。容器如果按镜像重建,这段会丢,再在 10.0.1.47 上执行本文件。
不重启 vLLM、New-API、dify-web-1。执行完会重启 dify-worker-1。
"""
from __future__ import annotations
import subprocess
from pathlib import Path
TRACE_PATH = "/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py"
MARKER = "# AGENT_TOOL_SPANS_V2"
ANCHOR = """ self.add_generation(langfuse_generation_data)
def moderation_trace(self, trace_info: ModerationTraceInfo):
"""
BLOCK = """ try:
langfuse_generation_data.id = str(message_id)
except Exception:
pass
self.add_generation(langfuse_generation_data)
# AGENT_TOOL_SPANS_V2
try:
from sqlalchemy import select
from core.db.session_factory import session_factory
from models.model import MessageAgentThought
with session_factory.create_session() as session:
thoughts = session.scalars(
select(MessageAgentThought)
.where(MessageAgentThought.message_id == str(message_id))
.order_by(MessageAgentThought.position.asc())
).all()
logger.info("agent tool spans count=%s message_id=%s", len(thoughts), message_id)
for at in thoughts:
for tn in at.tools:
if not tn:
continue
tool_inp = at.tool_inputs_dict.get(tn)
outputs = at.tool_outputs_dict if isinstance(at.tool_outputs_dict, dict) else {}
tool_output = outputs.get(tn)
if tool_output in (None, "", {}):
tool_output = at.observation or None
meta = at.tool_meta.get(tn, {}) if isinstance(at.tool_meta, dict) else {}
time_cost_f = float(getattr(at, "latency", 0) or 0)
if not time_cost_f:
try:
time_cost_f = float((meta or {}).get("time_cost") or 0)
except (TypeError, ValueError):
time_cost_f = 0.0
if not time_cost_f:
time_cost_f = 0.2
start_t = at.created_at or trace_info.start_time
if start_t is not None and getattr(start_t, "tzinfo", None) is None:
start_t = start_t.replace(tzinfo=UTC)
end_t = start_t + timedelta(seconds=time_cost_f) if start_t else None
err = (meta or {}).get("error")
self.add_span(
LangfuseSpan(
name=tn,
input=tool_inp,
output=tool_output,
trace_id=trace_id,
parent_observation_id=str(message_id),
start_time=start_t,
end_time=end_t,
metadata={**metadata, "tool_name": tn, "time_cost": time_cost_f},
level=(LevelEnum.DEFAULT if not err else LevelEnum.ERROR),
status_message=str(err or ""),
)
)
except Exception:
logger.exception("agent tool spans patch failed")
def moderation_trace(self, trace_info: ModerationTraceInfo):
"""
ID_OLD = 'id=data.get("id"),'
ID_NEW = 'id=data.get("id") or self._make_event_id(),'
def patch_text(text: str) -> str:
if MARKER not in text:
if ANCHOR not in text:
raise SystemExit("找不到 message_trace 锚点,文件版本可能已变")
text = text.replace(ANCHOR, BLOCK, 1)
text = text.replace(ID_OLD, ID_NEW)
return text
def write_container(container: str, text: str) -> None:
remote = Path("/tmp/langfuse_trace.py")
remote.write_text(text, encoding="utf-8")
subprocess.check_call(["docker", "cp", str(remote), "%s:%s" % (container, TRACE_PATH)])
check = subprocess.run(
["docker", "exec", container, "python", "-m", "py_compile", TRACE_PATH],
capture_output=True,
text=True,
)
if check.returncode != 0:
raise SystemExit(check.stderr)
print(container, "ok")
def main() -> None:
raw = subprocess.check_output(["docker", "exec", "dify-worker-1", "cat", TRACE_PATH], text=True)
patched = patch_text(raw)
write_container("dify-worker-1", patched)
write_container("dify-api-1", patched)
subprocess.check_call(["docker", "restart", "dify-worker-1"])
print("dify-worker-1 已重启")
if __name__ == "__main__":
main()二、功能操作案例
案例 1:按这个架构装上,不要影响现有服务
提问:实现「浏览器 → Dify → New-API → vLLM,向量走 Xinference bge-m3」。服务器上已经有服务,不要影响它们。
这一步新装了两样东西:Dify,以及当时跑在 CPU 上的 Xinference(向量模型 bge-m3)。当时没有改 vLLM,显存仍大约 85GB。对话请求的路径是 Dify → New-API → vLLM(GPU),测过一次,HTTP 200。知识库向量请求的路径是 Dify → Xinference,bge-m3 返回的向量维度是 1024。向量和重排后来改到 GPU,见案例 7。
案例 2:启用 Rerank
提问:启用 Rerank。
重排模型当时和向量模型放在一起。加载前把 xinference-bge 内存从 8G 调到 12G。现在 n_gpu 是 1,跑在 GPU 0 上。
文件:scripts/04-启用Rerank.sh
#!/bin/bash
# 已执行。重排模型在 GPU 0 上,Dify 默认重排模型是 bge-reranker-v2-m3。
set -eu
echo "先确认 xinference-bge 能看到 GPU,并且内存是 12G"
docker inspect xinference-bge --format 'memory={{.HostConfig.Memory}} gpu={{json .Config.Env}}'
echo "模型加载请求:"
cat <<'EOF'
POST http://127.0.0.1:9997/v1/models?wait_ready=true
Authorization: Bearer <Xinference 管理员密钥>
{
"model_name": "bge-reranker-v2-m3",
"model_type": "rerank",
"n_gpu": 1,
"download_hub": "modelscope"
}
EOF
echo "Dify 里把默认 rerank 设为 langgenius/xinference/xinference 的 bge-reranker-v2-m3。"
echo "新建知识库时,检索设置里打开 Rerank,选这个模型。"用「苹果」做过一次重排:水果文档分数约 0.97,天气文档接近 0。Dify 的默认重排模型已设为 bge-reranker-v2-m3。当时还没有知识库,所以没有逐个知识库去勾选;新建知识库时要在检索设置里打开 Rerank。
案例 3:给「阿里云合同条款」加上知识检索
提问:Dify 里新增了名为「阿里云合同条款」的 Agent,优化一下,加上知识检索。
这个 Agent 当时的提示词已经要求引用知识库原文,但 knowledge.sets 是空的。合同 PDF 只作为配置文件上传,没有进知识库。
标识:
| 项 | 值 |
|---|---|
| Agent | 01a1236c-4cc3-7fb9-bfce-c730a318bdf9 |
| 应用 | d2e3f57b-3901-45de-bd9a-d51feb849f6e |
| 合同文件 | c74afc20-f95b-4be1-b548-aad73abb5325 |
| 知识库 | 71042108-5f9a-4856-a0db-4359e8e7fa98 |
创建知识库的接口是 POST /console/api/datasets/init。索引用高质量模式、bge-m3、中文、自动分段、混合检索,并打开重排。索引结果:1 份文档、27190 字、75 个片段。知识库权限改为 all_team_members。
文件:scripts/05-合同知识库.py
#!/usr/bin/env python3
"""给「阿里云合同条款」接上知识库。现网已经执行过,知识库 id 固定。
重复跑「创建知识库」会再索引一份文档。只在知识库不存在时使用创建请求。
"""
DATASET_ID = "71042108-5f9a-4856-a0db-4359e8e7fa98"
FILE_ID = "c74afc20-f95b-4be1-b548-aad73abb5325"
AGENT_ID = "01a1236c-4cc3-7fb9-bfce-c730a318bdf9"
PROVIDER = "langgenius/xinference/xinference"
init_body = {
"name": "阿里云合同条款",
"indexing_technique": "high_quality",
"doc_form": "text_model",
"doc_language": "Chinese",
"embedding_model": "bge-m3",
"embedding_model_provider": PROVIDER,
"data_source": {
"info_list": {
"data_source_type": "upload_file",
"file_info_list": {"file_ids": [FILE_ID]},
}
},
"process_rule": {"mode": "automatic"},
"retrieval_model": {
"search_method": "hybrid_search",
"reranking_enable": True,
"reranking_mode": "reranking_model",
"reranking_model": {
"reranking_provider_name": PROVIDER,
"reranking_model_name": "bge-reranker-v2-m3",
},
"top_k": 4,
"score_threshold_enabled": False,
"weights": {
"weight_type": "customized",
"vector_setting": {
"vector_weight": 0.7,
"embedding_provider_name": PROVIDER,
"embedding_model_name": "bge-m3",
},
"keyword_setting": {"keyword_weight": 0.3},
},
},
}
knowledge_set = {
"id": "retrieval-1",
"name": "Retrieval 1",
"description": "阿里云框架合同原文及附件",
"datasets": [{"id": DATASET_ID, "name": "阿里云合同条款"}],
"query": {"mode": "generated_query"},
"retrieval": {
"mode": "multiple",
"top_k": 4,
"reranking_mode": "reranking_model",
"reranking_enable": True,
"reranking_model": {"provider": PROVIDER, "model": "bge-reranker-v2-m3"},
},
"metadata_filtering": {"mode": "disabled"},
}Agent v2 的知识库写在 knowledge.sets 里。查询模式要用 generated_query。这种模式会给模型一个工具 knowledge_base_search(set_name, query),按用户的问题现查。user_query 反而要求一段固定查询文本,页面一打开就查那一句,不适合问答。
保存草稿:
PUT /console/api/agent/01a1236c-4cc3-7fb9-bfce-c730a318bdf9/composer
{"variant":"agent_app","save_strategy":"save_to_current_version","agent_soul": ...}save_as_new_version 会被拒绝,版本只能走发布接口:
POST /console/api/agent/01a1236c-4cc3-7fb9-bfce-c730a318bdf9/publish
{"version_note":"接入合同知识库,开启混合检索、Rerank 和引用"}系统提示词已换成精简版:需要原文时调用一次 knowledge_base_search,set_name 为「Retrieval 1」;同一条款已在本轮引用过则直接作答;检索不到时使用固定未命中回复。普通问答只答所问,只有用户要求总结或报告时才问要不要出清单。引用、开场白和四个建议问题仍开着。知识库卡片仍是 Retrieval 1。
召回测试:
| 查询 | 最高分 |
|---|---|
| 保密信息 | 0.94 |
| 承诺保底消费 | 0.87 |
| 责任上限 | 0.05 |
「责任上限」分数低,是因为这份合同里没有贴得很近的表述。提示词里的规则仍然有效:检索不到就说明知识库没有这条,不编造。后来在 Build 对话里问责任上限和有效期,回答已经引用了合同原文,包括 2025-06-13 到 2027-06-12 的有效期。
案例 4:配置页上要出现「知识检索 / Retrieval 1」
提问:给这个 Agent 配上配置页里的「知识检索」,卡片名称是 Retrieval 1。
知识库当时已经绑上了,但配置页不显示这一栏。Dify 1.17.1 的前端把 ENABLE_AGENT_KNOWLEDGE_RETRIEVAL 编译成关闭(!1)。官方入口脚本不能在运行时打开它。
文件:scripts/06-打开知识检索开关.py
#!/usr/bin/env python3
"""打开 Dify 配置页上的「知识检索」。官方镜像把这个开关编译成关闭。
只改 dify-web-1 里的前端包,然后重启这一个容器。
不要重启 dify-api、New-API 或 vLLM。
容器如果被删除重建,镜像里的开关会回到关闭,需要再执行一次。
"""
import subprocess
OLD = 'ENABLE_AGENT_KNOWLEDGE_RETRIEVAL",0,!1'
NEW = 'ENABLE_AGENT_KNOWLEDGE_RETRIEVAL",0,!0'
ROOT = "/app/targets/next/web/.next"
listing = subprocess.check_output(
["docker", "exec", "dify-web-1", "grep", "-R", "-F", "-l", "ENABLE_AGENT_KNOWLEDGE_RETRIEVAL", ROOT],
text=True,
)
changed = 0
for path in [line.strip() for line in listing.splitlines() if line.strip()]:
local = "/tmp/" + path.strip("/").replace("/", "_")
subprocess.check_call(["docker", "cp", f"dify-web-1:{path}", local])
text = open(local, encoding="utf-8", errors="replace").read()
count = text.count(OLD)
if count == 0:
print("skip", path, "already", text.count(NEW))
continue
open(local, "w", encoding="utf-8").write(text.replace(OLD, NEW))
subprocess.check_call(["docker", "cp", local, f"dify-web-1:{path}"])
print("patched", path, count)
changed += count
if changed:
subprocess.check_call(["docker", "restart", "dify-web-1"])
print("restarted dify-web-1")
else:
print("flag already enabled")浏览器会长期缓存原来的 02gs9pr6mwqf2.js。现场把引用改成了 02gs9pr6mwqf2kr.js,只重启了 dify-web-1。配置页现在有「Knowledge Retrieval / 知识检索」,卡片名是 Retrieval 1,里面的知识库是「阿里云合同条款」。
docker restart dify-web-1 会保留这次修改。如果这个容器被删掉后用原镜像重建,开关会回到关闭,需要再跑 06。
案例 5:怎么给这把 Key 设置金额限制和过期限制
提问:这个 Agent 用的是 New-API 的那个用户。怎么设置并显示金额限制、模型过期限制,针对正在用的那把 API Key。
Agent 没有单独的 New-API 用户。对话密钥是工作区模型供应商里的 dify-gateway,属主是 admin。金额和过期都在 New-API 的令牌页上改,Dify 的 Agent 配置页没有这两项。
当前:额度无限,永不过期,模型只允许 qwen3.6-27b。令牌无限时,花费记在 admin 余额上。
设置步骤:
- 打开 http://10.0.1.47:3000 ,用 admin 登录。
- 进入「令牌」,编辑 dify-gateway。
- 金额限制:关掉「无限额度」,填一个金额。保存后列表显示剩余额度和已用额度。用完后请求会因额度不足被拒绝。
- 过期限制:把过期时间改成具体日期。到期后状态变成已过期,调用返回无效令牌。
- 模型限制已经打开,只放行
qwen3.6-27b。这一项限制的是能调用的模型,过期时间是另一项。
改完不用改 Dify。下一次提问就按这把令牌的额度和过期时间执行。具体金额和日期还没有填,因为提问里没有给数值。
案例 6:以后新建 Agent 或工作流,是不是默认都用这把令牌
提问:后续无论增加 Agent、工作流还是其他应用,都默认用 dify-gateway(sk-C0P9 开头)吗?
对话会默认用这把令牌。新建 Agent、工作流或其他应用时,只要模型保持工作区默认的 qwen3.6-27b,请求都走 New-API 的 dify-gateway。密钥配在工作区的模型供应商上,不是配在某一个 Agent 里。
知识库的向量和重排不走这把令牌,它们走 Xinference 的 bge-m3 和 bge-reranker-v2-m3。
如果某个应用改选了别的模型,或者在模型供应商里换了密钥,就按新的密钥计费。不改的话,所有对话继续记在 dify-gateway 上。
案例 7:Xinference 改到 GPU,并稍微降低 vLLM 显存比例
提问:Xinference 可以改成 GPU 吗,降低一点 vLLM 的 GPU 占用比例。
vLLM 只改了 --gpu-memory-utilization,从 0.75 改为 0.70。--max-model-len 65536、--max-num-seqs 8 没动。重建容器时保留了原来的镜像、/models/Qwen3.6-27B 挂载、--shm-size 8g、HF_HUB_OFFLINE=1 和端口 8000。加载完成后 /v1/models 能返回 qwen3.6-27b。EngineCore 显存从大约 73724 MiB 降到 67044 MiB。
Xinference 去掉了 NVIDIA_VISIBLE_DEVICES=void,容器改为 --gpus all。模型名仍是 bge-m3 和 bge-reranker-v2-m3,n_gpu 为 1。Dify 里的地址和模型名不用改。重建容器后重新安装了 sentence-transformers。
改完后的显存:
| 进程 | 显存 |
|---|---|
| vLLM EngineCore | 67044 MiB |
| bge-m3 | 2662 MiB |
| bge-reranker-v2-m3 | 2658 MiB |
| 整卡已用 / 空闲 | 84129 MiB / 13373 MiB |
嵌入维度仍是 1024。重排用「苹果」测过,水果文档分数高于无关文档。对话服务在 vLLM 重新加载期间中断了几分钟,之后恢复。New-API 和其他 Dify 容器没有动。
案例 8:用 Langfuse 看全链路,并算清这次花了多少钱
提问:通过 Langfuse 验证两件事。一是 Traces,看 AI 在后台磨蹭什么。二是 Cost 和 Token,算清谁花了多少钱。
合同 Agent「阿里云合同条款」在追踪打开之后的一条问答,已经对上。
| 项 | 值 |
|---|---|
| trace / message | 373f2dcb-fd1e-4a4a-84d4-871050011a4b |
| 时间 | 2026-10-10 14:58(北京时间) |
| 用户原话 | 其实吧 这只是纠纷的时候才会刨根稳定的一个合同 平时除了折扣没啥用 |
| 链路 | 一条 message,下面一个 llm |
| 模型 | qwen3.6-27b |
| 耗时 | 18 秒 |
| token | 提示 26763,补全 862,合计 27625 |
| 令牌 | dify-gateway,用户 admin |
| New-API 日志 | 第 125 条,额度 13813 |
| 金额 | 13813 / 500000 = 0.027626 美元 |
同一分钟还有一笔同令牌调用:提示 3127、补全 256、额度 1692,合 0.003384 美元。两笔合计 0.031010 美元。额度换算是现场 QuotaPerUnit=500000,即 500000 额度 = 1 美元。
这条 llm 的费用来自 New-API 的额度。Dify 里该模型凭证没有单价,messages.total_price 是 0。Langfuse 上这条追踪的总成本已写成 0.027626 美元。以后新的对话会带上 token;美元数仍要看 New-API 额度,除非给模型凭证填上单价。
合同 Agent 的知识检索写在那 26763 个提示 token 里。这条追踪的 Observation 名字是 llm。
案例 9:生成 getServerStatus,并在 Langfuse 里看到这个名字
提问:生成符合条件的工具,把文档里的工具都生成出来。
那份追踪验收只要求一个工具名:getServerStatus。它来自 OpenAPI 的 operationId。Agent 调用后,第 7 节的补丁把 message_agent_thoughts.tool 写成 Observation 的 Name。
页面上看:Langfuse → Tracing → Observations,筛选 Name = getServerStatus。
自动写进去的一条是 gpu-server-03:
| 项 | 值 |
|---|---|
| trace | 9b871f94-02d9-4663-be2d-bbd703997633 |
| 类型 | SPAN |
| 名称 | getServerStatus |
| 输入 | server_name = gpu-server-03 |
| 输出 | CPU 45%,内存 62%,GPU 利用率 78%,显存 18.2/24 GB |
| 开始 / 结束 | 2026-10-10T07:53:25.918Z / 2026-10-10T07:53:26.118Z |
| 同一次的模型节点 | llm |
同一 Agent 上还有两条同名 Span:gpu-server-01 的 trace dab7b462-cbbb-47ea-81a2-d83f32af3f45,gpu-server-02 的 trace 82c4b70d-1aba-4c46-a87d-a87a35d19b53。问句是「帮我看看 gpu-server-0N 的状态」。模拟接口对任何服务器名都返回上面这组固定指标。
写文档时核对到的数据
- Dify 页面 http://10.0.1.47:8088 ,容器已运行约 16 小时。
dify-web-1为了打开知识检索重启过,当时已运行约 14 分钟。 - Xinference
xinference-bge端口 9997,内存 12G,4 核,--gpus all。bge-m3和bge-reranker-v2-m3都在 GPU 0。 - 合同知识库 75 个片段、27190 字,混合检索,重排模型
bge-reranker-v2-m3,top_k 4。 - 配置页知识检索卡片名称是 Retrieval 1。
- dify-gateway 仍是无限额度、永不过期,模型限制
qwen3.6-27b。 - vLLM
--gpu-memory-utilization现为0.70,上下文长度 65536。New-API 没有重启。 - 状态接口
server-status在 8777,/health返回 ok。OpenAPI 地址http://172.17.0.1:8777。 - 工具
server_status/getServerStatus,providera69388a5-c02f-4bf1-8c28-1f20ee0687e6。 - 运维巡检 Agent
01a124c7-9fce-70a8-9efa-41b2a6f5b7e7,应用e4d614f6-de22-4f4c-b58e-caeb0504b121。 SSRF_PROXY_ALLOW_PRIVATE_IPS=172.17.0.0/16,10.0.0.0/8。dify-web-1没有为这件事重建。- Langfuse 页面 http://10.0.1.47:3100 。gpu-server-03 的工具 Span 在 trace
9b871f94-02d9-4663-be2d-bbd703997633。 - 合同问答 trace
373f2dcb-fd1e-4a4a-84d4-871050011a4b:26763 + 862 token,New-API 额度 13813,合 0.027626 美元。