Dify · 知识库

用 Dify 做合同知识库:Qwen 对话、bge 重排和 Langfuse 工具追踪

在 Dify 里搭一套合同条款知识库:Qwen 对话模型、bge-m3 向量与重排、服务器状态工具,以及 Langfuse 上的工具调用追踪,全链路串通。

用 Dify 做合同知识库:Qwen 对话、bge 重排和 Langfuse 工具追踪

目录

  1. 一、安装配置
  2. 二、功能操作案例
  3. 写文档时核对到的数据

服务器内网: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.yamlOpenAPI,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:

text
浏览器
  → 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 没有重启。

动手前已经在跑的服务:

服务容器地址
vLLMvllm-qwen3.6-27bhttp://10.0.1.47:8000/v1
New-APInew-apihttp://10.0.1.47:3000
主机 Nginx占用 80 端口不要动
Kuberneteskubelet、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

bash
#!/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:

text
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/8

SSRF_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 / websocketlanggenius/dify-api:1.17.1
dify-weblanggenius/dify-web:1.17.1
dify-agent_backendlanggenius/dify-agent-backend:1.17.1
dify-sandboxlanggenius/dify-sandbox:0.2.15
dify-local_sandboxlanggenius/dify-agent-local-sandbox:1.17.1
dify-plugin_daemonlanggenius/dify-plugin-daemon:0.6.10-local
dify-db_postgrespostgres:15-alpine
dify-pgvectorpgvector/pgvector:pg16
dify-redisredis:6-alpine
dify-nginxnginx: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。

text
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

bash
#!/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,已经换成现在这条命令。

向量模型:

json
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_compatibleqwen3.6-27bhttp://172.17.0.1:3000/v1
向量langgenius/xinference/xinferencebge-m3http://172.17.0.1:9997
重排langgenius/xinference/xinferencebge-reranker-v2-m3http://172.17.0.1:9997

对话模型上下文 65536,单次输出上限 8192,函数调用类型 tool_call。经 New-API 打过一次对话,HTTP 200。

文件:scripts/03-接入对话和向量模型.py

python
#!/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",
    },
}

保存接口:

text
POST /console/api/workspaces/current/model-providers/<provider>/models/credentials
POST /console/api/workspaces/current/default-model

供应商路径:

插件安装时如果返回 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

bash
#!/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

python
#!/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

ini
[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

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_id01a124c7-9fce-70a8-9efa-41b2a6f5b7e7
app_ide4d614f6-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

python
#!/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

python
#!/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

bash
#!/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 只作为配置文件上传,没有进知识库。

标识:

项值
Agent01a1236c-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

python
#!/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 反而要求一段固定查询文本,页面一打开就查那一句,不适合问答。

保存草稿:

text
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 会被拒绝,版本只能走发布接口:

text
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

python
#!/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 余额上。

设置步骤:

  1. 打开 http://10.0.1.47:3000 ,用 admin 登录。
  2. 进入「令牌」,编辑 dify-gateway。
  3. 金额限制:关掉「无限额度」,填一个金额。保存后列表显示剩余额度和已用额度。用完后请求会因额度不足被拒绝。
  4. 过期限制:把过期时间改成具体日期。到期后状态变成已过期,调用返回无效令牌。
  5. 模型限制已经打开,只放行 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 EngineCore67044 MiB
bge-m32662 MiB
bge-reranker-v2-m32658 MiB
整卡已用 / 空闲84129 MiB / 13373 MiB

嵌入维度仍是 1024。重排用「苹果」测过,水果文档分数高于无关文档。对话服务在 vLLM 重新加载期间中断了几分钟,之后恢复。New-API 和其他 Dify 容器没有动。


案例 8:用 Langfuse 看全链路,并算清这次花了多少钱

提问:通过 Langfuse 验证两件事。一是 Traces,看 AI 在后台磨蹭什么。二是 Cost 和 Token,算清谁花了多少钱。

合同 Agent「阿里云合同条款」在追踪打开之后的一条问答,已经对上。

项值
trace / message373f2dcb-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:

项值
trace9b871f94-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 的状态」。模拟接口对任何服务器名都返回上面这组固定指标。

写文档时核对到的数据