# DeepSeek DSH 进阶实战：离线化 · 工单化 · 平台化

> 前置说明：DSH 仍处开发者预览阶段，CLI 子命令与插件清单字段可能随版本调整。
> 本文所有命令以 `dsh --help` / `dsh plugin --help` 的实际输出为准；架构与集成思路是稳定的，参数名请现场核对。

---

## 九、方向一：完全离线本地 Agent（Ollama / llama.cpp）

### 9.1 先算账：Agent 场景的显存不是"能跑模型"那么简单

普通聊天只需要装下权重；Agent 需要装下 **权重 + 工具定义 + 完整轨迹 + KV Cache**，而轨迹会随轮次线性增长。

估算公式：

```
总显存 ≈ 权重(GB) + KV_Cache(GB) + 激活开销(0.5~1.5GB)

KV_Cache(GB) ≈ 2 × 层数 × KV头数 × head_dim × 上下文长度 × 字节数 / 1024³
```

以 Qwen2.5-Coder-14B（48 层、8 个 KV 头、head_dim 128）为例，Q8 KV、32K 上下文：

```
2 × 48 × 8 × 128 × 32768 × 2B ≈ 6.4 GB
```

| 模型 | 量化 | 权重 | KV@32K(Q8) | 合计 | 建议显存 | 24G 单卡 |
|---|---|---|---|---|---|---|
| Qwen2.5-Coder-7B | Q4_K_M | 4.7G | 3.2G | ~8G | 12G | 轻松，可并行 2 |
| Qwen2.5-Coder-14B | Q4_K_M | 9.0G | 6.4G | ~16G | 20G | 可用，上下文限 32K |
| Qwen3-32B | Q4_K_M | 19G | 8~10G | ~29G | 32G+ | 需 offload 或降到 Q4 KV、16K |
| Llama-3.3-70B | Q4_K_M | 40G | 12G+ | 52G+ | 双卡/48G+ | 不可行 |

**结论**：做 Agent 而不是做聊天，14B 是 24G 卡的甜点；要上 32B 就必须接受 16K 上下文 + Q4 KV，或者上 48G/80G 卡。
你的 4090 集群可以把"模型服务"和"DSH 运行时"拆成两台机器，用内网 HTTP 连，互不抢显存。

**工具调用能力筛选**（这是 Agent 的生死线，比参数量更重要）：

| 模型 | 原生 tool calling | 中文指令跟随 | 备注 |
|---|---|---|---|
| Qwen2.5 / Qwen3 系列（含 Coder） | ✅ 训练过 | 强 | 首选 |
| Llama-3.1 / 3.3 | ✅ | 中 | 英文任务好 |
| DeepSeek-R1 蒸馏版 | ❌ 无 tool 训练 | 强 | 只适合纯推理，做 Agent 会退化成"输出文字假装调用" |
| Mistral / Gemma3 | 部分 | 中 | 需实测 schema 遵循率 |

选型口诀：**先测 schema 遵循率，再比 tokens/s**。一个能稳定输出合法 JSON 的 14B，胜过一个乱吐函数名的 32B。

---

### 9.2 Ollama 路径：最省事的离线底座

#### 安装（离线环境）

联网机器上准备离线包：

```bash
curl -L -O https://ollama.com/download/ollama-linux-amd64.tgz
# 内网机器
sudo tar -xzf ollama-linux-amd64.tgz -C /usr
sudo useradd -r -s /bin/false -m -d /usr/share/ollama ollama 2>/dev/null
```

systemd 单元（`/etc/systemd/system/ollama.service`）：

```ini
[Unit]
Description=Ollama
After=network-online.target

[Service]
User=ollama
Group=ollama
ExecStart=/usr/bin/ollama serve
Restart=always
RestartSec=3
# 关键：给 Agent 场景调参
Environment="OLLAMA_HOST=0.0.0.0:11434"
Environment="OLLAMA_MODELS=/data/ollama/models"
Environment="OLLAMA_KEEP_ALIVE=-1"
Environment="OLLAMA_NUM_PARALLEL=1"
Environment="OLLAMA_MAX_LOADED_MODELS=1"
Environment="OLLAMA_CONTEXT_LENGTH=32768"
Environment="OLLAMA_FLASH_ATTENTION=1"
Environment="OLLAMA_KV_CACHE_TYPE=q8_0"
Environment="CUDA_VISIBLE_DEVICES=0"

[Install]
WantedBy=multi-user.target
```

逐条说明，这几项直接决定 Agent 好不好用：

| 变量 | 值 | 为什么 |
|---|---|---|
| `OLLAMA_KEEP_ALIVE` | `-1` | Agent 一次任务里会连调十几次模型，卸载重载一次要 5~15 秒，必须常驻 |
| `OLLAMA_MAX_LOADED_MODELS` | `1` | 单卡上限。让 7B/14B 来回切换会疯狂换入换出 |
| `OLLAMA_NUM_PARALLEL` | `1` | 并发 2 会把显存翻倍吃掉 KV。子 Agent 并行是显存杀手 |
| `OLLAMA_CONTEXT_LENGTH` | 与模型匹配 | 默认 2048 根本不够 Agent，工具定义就塞满了 |
| `OLLAMA_KV_CACHE_TYPE` | `q8_0` | KV 显存直接砍半，精度损失在 Agent 场景几乎无感 |
| `OLLAMA_FLASH_ATTENTION` | `1` | 长上下文必开，否则慢到不可用 |

#### 用 Modelfile 造一个"Agent 专用"模型

不要把原始模型直接挂给 DSH。造一个带系统约束、低温度、足够上下文的定制版本：

`dsh-coder.Modelfile`

```
FROM qwen2.5-coder:14b-instruct-q4_K_M

PARAMETER num_ctx 32768
PARAMETER temperature 0.15
PARAMETER top_p 0.9
PARAMETER top_k 40
PARAMETER repeat_penalty 1.05
PARAMETER num_predict 4096

SYSTEM """You are an autonomous engineering agent running in a sandboxed workspace.
Rules:
1. Always inspect files before modifying them.
2. Never fabricate command output; if a command fails, report the exact error.
3. Prefer the smallest edit that satisfies the request.
4. When a tool call is required, emit it in the exact schema provided. Do not wrap it in prose.
5. Answer in the user's language."""
```

```bash
ollama create dsh-coder -f dsh-coder.Modelfile
ollama list                # 确认生成
ollama run dsh-coder "列出你能用的工具"
```

`temperature 0.15` 是为了让 JSON schema 稳定；`repeat_penalty 1.05` 防止 14B 在长轨迹里陷入重复循环（小模型做 Agent 最常见的事故）。

#### DSH 侧接入配置

`~/.dsh/config.yaml`（或 Settings → Models 里等价填写）：

```yaml
models:
  - id: local-coder
    label: 本地 Qwen2.5-Coder-14B
    provider: openai-compatible
    base_url: http://127.0.0.1:11434/v1
    api_key: ollama          # Ollama 不校验，占位即可
    model: dsh-coder
    context_window: 32768
    max_output_tokens: 4096
    supports_tools: true
    request_timeout_ms: 600000     # 本地推理慢，默认超时一定会炸
    stream: true
  - id: local-reasoner
    label: 本地 Qwen3-32B（重活）
    provider: openai-compatible
    base_url: http://10.0.0.20:11434/v1
    api_key: ollama
    model: qwen3:32b
    context_window: 16384
    max_output_tokens: 8192
    supports_tools: true
    request_timeout_ms: 900000
```

默认模型用环境变量钉死，避免 UI 每次手选：

```bash
export DSH_MODEL=local-coder
```

**必调的两个数**：`request_timeout_ms`（本地推理动辄 60~300 秒，默认值会直接中断任务）、`max_output_tokens`（本地模型容易写超长，卡住整个流程）。

顺带把 DSH 的轨迹压缩调严：本地 32K 上下文，留 8K 给轨迹，超过就滚动摘要，否则第三轮就撑爆。

---

### 9.3 llama.cpp 路径：要吞吐、要多卡、要细粒度控制

Ollama 本质是 llama.cpp 的封装。当你需要：

- 多卡张量并行（`-sm row`）
- 精确控制 KV 量化、prompt cache 复用
- 自定义 chat template / 强制 jinja 工具调用
- 单机多模型端口隔离

就该直接上 llama.cpp。

#### 编译（CUDA）

```bash
git clone https://github.com/ggml-org/llama.cpp && cd llama.cpp
cmake -B build -DGGML_CUDA=ON -DGGML_CUDA_F16=ON -DCMAKE_BUILD_TYPE=Release
cmake --build build -j$(nproc) --target llama-server
```

#### 启动（Agent 生产配置）

```bash
./build/bin/llama-server \
  -m /data/models/Qwen2.5-Coder-14B-Instruct-Q4_K_M.gguf \
  --host 0.0.0.0 --port 8080 \
  -c 32768 \
  -ngl 99 \
  -fa \
  --cache-type-k q8_0 --cache-type-v q8_0 \
  --parallel 1 \
  --cont-batching \
  --chat-template-file ./templates/qwen25-tool.jinja \
  --jinja \
  --slot-save-path /data/slots \
  --log-file /var/log/llama-server.log
```

关键参数解释：

| 参数 | 作用 | Agent 场景要点 |
|---|---|---|
| `--jinja` | 用模型自带的对话模板渲染 | **不开这个，tool calling 基本不工作** |
| `--chat-template-file` | 覆盖模板 | 官方模板对 tools 支持不佳时自己改（下方给示例） |
| `-fa` | Flash Attention | 32K 上下文的速度命门 |
| `--cache-type-k/v q8_0` | KV 量化 | 省一半显存，32K 才塞得下 |
| `-ngl 99` | 全部层放 GPU | 显存不足时逐层下调，找不 OOM 的最大值 |
| `--slot-save-path` | 槽位缓存落盘 | 重启后 prompt cache 不用重建 |
| `--parallel 1` | 单并发 | 每个槽独立占 KV，多开会 OOM |

#### 工具调用模板碎片（`qwen25-tool.jinja` 核心段）

当官方模板把 tools 渲染错位时，最小改法是保证 `tools` 出现在 system 段、且 assistant 的工具调用用模型训练的 `<tool_call>` 标签：

```jinja
{%- if tools %}
{{- '<|im_start|>system\n' }}
{{- 'You can use the following functions by emitting JSON inside <tool_call> tags:\n' }}
{{- '<tools>' }}
{{- tools | tojson }}
{{- '</tools><|im_end|>\n' }}
{%- endif %}
```

改完用 `curl` 实测一次，不要盲信。

#### 验证工具调用是否真的可用

这是离线部署最容易翻车的一步，必须单独验：

```bash
curl -s http://127.0.0.1:8080/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "local",
    "messages": [{"role":"user","content":"统计 /tmp 下有几个文件，用工具做"}],
    "tools": [{
      "type":"function",
      "function":{
        "name":"count_files",
        "description":"统计目录内文件数量",
        "parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}
      }
    }],
    "tool_choice":"auto"
  }' | jq '.choices[0].message'
```

合格输出：`message.tool_calls[0].function.name == "count_files"`，参数是合法 JSON。
不合格表现：`content` 里出现一段自然语言描述"我将调用 count_files"——此时 DSH 会把它当普通回复，任务链断掉。

---

### 9.4 完全断网的交付包（内网/等保环境）

离线部署真正的难点不是模型，是 **依赖链**。推荐整机镜像，不要逐包搬运。

`Dockerfile`

```dockerfile
FROM node:22-bookworm-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
      ca-certificates curl git python3 make g++ \
    && rm -rf /var/lib/apt/lists/*

# 预置离线 npm 包（同版本依赖已打进 tgz）
COPY offline-pkgs/ /tmp/pkgs/
RUN npm config set registry https://registry.npmjs.org \
 && npm install -g /tmp/pkgs/*.tgz \
 && npm cache clean --force

COPY dsh-config.yaml /root/.dsh/config.yaml
ENV DSH_MODEL=local-coder
EXPOSE 3080
CMD ["dsh", "web", "--no-open", "--host", "0.0.0.0"]
```

联网机器上把依赖全量打进 tgz（这一步是关键，`npm pack` 不会自动带上传递依赖，要用 `pnpm` 的离线 store）：

```bash
pnpm add -g @deepseek-ai/dsh --store-dir ./pnpm-store
pnpm store export ./offline-pkgs   # 或在联网机上 build 镜像
docker build -t dsh:offline-1.0 .
docker save dsh:offline-1.0 | gzip -9 > dsh-offline-1.0.tar.gz
```

内网：

```bash
docker load < dsh-offline-1.0.tar.gz
```

模型文件单独走 U 盘（GB 级走镜像会让 tar 包巨大）：

```bash
ollama pull qwen2.5-coder:14b   # 联网机
# 模型在 /usr/share/ollama/.ollama/models，整个 blobs 目录拷走
rsync -av /usr/share/ollama/.ollama/models/ /mnt/usb/models/
```

内网侧 `OLLAMA_MODELS` 指过去即可，不用重新 pull。

`docker-compose.yml`（离线双机或单机双容器）

```yaml
services:
  ollama:
    image: ollama/ollama:0.5.13
    volumes:
      - /data/ollama/models:/root/.ollama/models
    environment:
      OLLAMA_KEEP_ALIVE: "-1"
      OLLAMA_MAX_LOADED_MODELS: "1"
      OLLAMA_NUM_PARALLEL: "1"
      OLLAMA_CONTEXT_LENGTH: "32768"
      OLLAMA_KV_CACHE_TYPE: "q8_0"
      OLLAMA_FLASH_ATTENTION: "1"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    ports: ["11434:11434"]
    restart: unless-stopped

  dsh:
    image: dsh:offline-1.0
    depends_on: [ollama]
    environment:
      DSH_MODEL: local-coder
      DSH_WEB_PORT: "3080"
      DSH_DEFAULT_WORKSPACE: /workspace
      DSH_OFFLINE: "1"
    volumes:
      - /data/workspace:/workspace
      - /data/dsh-home:/root/.dsh
    ports: ["3080:3080"]
    restart: unless-stopped
```

**断网自检清单**（部署完逐条过，任何一条不通就是没断干净）：

```bash
ss -tnp | grep -v 127.0.0.1        # 不应有外部连接
curl -sf http://localhost:11434/api/tags  # 模型列表本地返回
strace -f -e trace=network dsh web --no-open 2>&1 | grep -i 'connect('  # 追踪是否有外连
```

以及最容易漏的三处：DSH 的遥测/更新检查、WebUI 的 Google Fonts / CDN 资源、npm 的 registry 回退。全部通过环境变量或 hosts 屏蔽。

---

### 9.5 本地 Agent 的性能与稳定性调优

**速度**：14B Q4 在 4090 上 prefill 约 800~1500 tok/s，decode 约 30~50 tok/s。Agent 一轮任务 3000 token 输出 ≈ 1~2 分钟；能接受，但不能让模型发呆。

**四个高频事故与对策**：

| 现象 | 原因 | 对策 |
|---|---|---|
| 跑到一半超时中断 | 默认 request timeout 太短 | `request_timeout_ms: 600000` |
| 第 3~4 轮突然报 context 超限 | 轨迹累积 + 工具定义占位 | 开轨迹滚动摘要，`num_ctx` 拉满 |
| 模型反复输出同一段命令 | 小模型退化 / repeat penalty 过低 | `repeat_penalty 1.05`，并在工具结果里注入"成功/失败"明确标记 |
| 工具参数是字符串化的 JSON | 模板渲染或量化精度问题 | 换 Q5_K_M，或降到 7B Q8 试对比 |
| 并发两个任务后 OOM | `--parallel 2` + 两个 workspace | 单并发排队，别在 24G 卡上并行子 Agent |

**提示词缓存是最大提速项**：系统提示 + 工具定义是固定前缀，命中 prefix cache 能省掉几千 token 的 prefill。所以**不要频繁热改系统提示或工具列表**——每次改都会让缓存全失效，第一轮变慢 3~5 倍。

---

## 十、方向二：接入 n8n + GLPI，做运维自动工单 Agent

### 10.1 整体链路

```
监控告警 / 邮件 / 企业微信 / 用户填报
        │
        ▼
   n8n Webhook  (统一入口，做鉴权、去重、限流)
        │
        ▼
   DSH Bridge (HTTP 微服务，把 Agent 包成 API)
        │
        ├─→ 读取 GLPI 资产库 / 知识库（判断是否已知问题）
        ├─→ 在工作区检索历史工单解决方案
        └─→ 输出结构化 JSON：分类 / 优先级 / 处置方案 / 是否需人工
        │
        ▼
   n8n 分流
        ├─ L1 自动闭环 → 调 GLPI API 建单 + 自动回复 + 关单
        ├─ L2 → 建单 + 附建议方案，派给一线
        └─ L3 → 直接建单派二线，附完整诊断上下文
```

**为什么要 Bridge 而不是让 n8n 直接调模型**：Agent 的价值在于"会查、会算、会多步推理"，不是一问一答。Bridge 保留 DSH 的会话、工具、轨迹能力，n8n 只做流程编排和系统对接，职责清晰。

### 10.2 DSH Bridge：把 Agent 包成 HTTP 服务

`dsh_bridge.py`

```python
import json, subprocess, uuid, tempfile, os
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel

app = FastAPI()
BRIDGE_TOKEN = os.environ["DSH_BRIDGE_TOKEN"]

class TicketIn(BaseModel):
    title: str
    content: str
    requester: str | None = None
    asset: str | None = None      # 资产编号 / 主机名
    source: str = "webhook"

PROMPT_TMPL = """你是运维工单分诊专家。请基于以下信息判断并输出。
【工单标题】{title}
【工单内容】{content}
【报障人】{requester}
【相关资产】{asset}

要求：
1. 先在当前工作区检索历史工单与知识库（knowledge/ 目录），确认是否为已知问题。
2. 若为已知问题，给出已验证的处理步骤。
3. 若信息不足，明确指出缺失项，不要猜测。
4. 严禁执行任何写操作命令，只做分析与检索。

仅输出如下 JSON，不要任何额外文字：
{{
  "category": "账号权限|网络|打印|硬件|软件|邮箱|其他",
  "priority": 1-5,
  "is_known_issue": true|false,
  "solution_steps": ["..."],
  "confidence": 0.0-1.0,
  "need_human": true|false,
  "reason": "一句话说明判断依据"
}}"""

@app.post("/analyze")
def analyze(t: TicketIn, x_token: str = Header(default="")):
    if x_token != BRIDGE_TOKEN:
        raise HTTPException(401, "bad token")

    prompt = PROMPT_TMPL.format(**t.dict())
    run_id = uuid.uuid4().hex[:8]

    cmd = [
        "dsh", "run",
        "--workspace", "/data/dsh-workspace/it-ops",
        "--preset", "minimal",
        "--json",
        "--timeout", "300",
        prompt,
    ]
    r = subprocess.run(cmd, capture_output=True, text=True, timeout=360)
    if r.returncode != 0:
        return {"ok": False, "error": r.stderr[-800:], "run_id": run_id}

    try:
        out = json.loads(r.stdout)
        answer = out.get("final_answer") or out.get("content", "")
    except json.JSONDecodeError:
        answer = r.stdout

    # 从回答里抽出 JSON 块（本地模型偶尔会包一层 ```json）
    s, e = answer.find("{"), answer.rfind("}")
    try:
        result = json.loads(answer[s:e+1]) if s >= 0 else {}
    except json.JSONDecodeError:
        result = {}

    return {"ok": True, "run_id": run_id, "result": result, "raw": answer[:2000]}
```

要点：
- `--preset minimal`：只需要检索和分析，不给 Shell 写权限，**这是安全边界**，别用 standard 模式跑暴露给外网的入口。
- `--timeout 300` + subprocess timeout 双层兜底。
- 输出清洗：本地模型 90% 的"格式错误"都是多包了一层 markdown 代码块，用首尾大括号提取即可。

启动：

```bash
export DSH_BRIDGE_TOKEN=$(openssl rand -hex 24)
uvicorn dsh_bridge:app --host 0.0.0.0 --port 8090
```

### 10.3 GLPI 侧：REST API 完整对话

**① 拿 session token**

```bash
curl -s -X GET "http://glpi.local/apirest.php/initSession" \
  -H "App-Token: ${APP_TOKEN}" \
  -H "Authorization: user_token ${USER_TOKEN}" \
  -H "Content-Type: application/json"
# → {"session_token":"abc123..."}
```

**② 查资产/历史工单（喂给 Agent 做上下文）**

```bash
curl -s -X GET "http://glpi.local/apirest.php/search/Ticket" \
  -H "Session-Token: ${SESSION}" -H "App-Token: ${APP_TOKEN}" \
  --data-urlencode "criteria[0][field]=12" \
  --data-urlencode "criteria[0][searchtype]=contains" \
  --data-urlencode "criteria[0][value]=打印机脱机" \
  --data-urlencode "range=0-9"
```

**③ 创建工单**

```bash
curl -s -X POST "http://glpi.local/apirest.php/Ticket" \
  -H "Session-Token: ${SESSION}" -H "App-Token: ${APP_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "input": {
      "name": "3楼财务打印机持续脱机",
      "content": "报障人：张会计\n现象：任务队列堆积，重启打印服务后 10 分钟复发\n\nAI 初步分析：\n1. 检查 \\\\PRN-FIN-01 的 Print Spooler 服务状态\n2. 核对 DHCP 保留地址是否被回收\n3. 疑似网段 ARP 冲突，建议抓包确认",
      "type": 1,
      "urgency": 3,
      "impact": 2,
      "itilcategories_id": 12,
      "status": 1
    }
  }'
```

**④ 回写跟进记录**

```bash
curl -s -X POST "http://glpi.local/apirest.php/TicketFollowup" \
  -H "Session-Token: ${SESSION}" -H "App-Token: ${APP_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"input":{"tickets_id":1234,"content":"[自动] 知识库命中 KB-PRN-007，已推送处理步骤给用户","is_private":0}}'
```

**⑤ 关单（仅 L1 自动闭环时）**

```bash
curl -s -X PUT "http://glpi.local/apirest.php/Ticket/1234" \
  -H "Session-Token: ${SESSION}" -H "App-Token: ${APP_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"input":{"status":5,"solution":"用户已按知识库步骤自助解决"}}'
```

记得用完 `killSession`，GLPI 默认 session 有效期短，长跑进程要做"取不到就重取"的重试封装。

### 10.4 n8n 工作流（可直接导入）

`it-ticket-agent.json`（精简版，导入后补凭证与地址）

```json
{
  "name": "GLPI 自动分诊 Agent",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "glpi-triage",
        "authentication": "headerAuth",
        "responseMode": "responseNode",
        "options": {}
      },
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [0, 0]
    },
    {
      "parameters": {
        "jsCode": "const b = $json.body || $json;\nif (!b.title) { throw new Error('missing title'); }\nreturn [{ json: {\n  title: b.title,\n  content: b.content || '',\n  requester: b.requester || 'unknown',\n  asset: b.asset || '',\n  source: b.source || 'webhook'\n}}];"
      },
      "name": "规范化输入",
      "type": "n8n-nodes-base.code",
      "position": [220, 0]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://dsh-bridge:8090/analyze",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [{ "name": "x-token", "value": "={{ $env.DSH_BRIDGE_TOKEN }}" }]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": { "timeout": 360000, "retryOnFail": true, "maxTries": 2, "waitBetweenTries": 5000 }
      },
      "name": "调 DSH Agent",
      "type": "n8n-nodes-base.httpRequest",
      "position": [440, 0]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": { "caseSensitive": true },
                "conditions": [
                  { "leftValue": "={{ $json.result.need_human }}", "rightValue": false, "operator": "boolean" },
                  { "leftValue": "={{ $json.result.confidence }}", "rightValue": 0.8, "operator": "gte" }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "自动闭环"
            },
            {
              "conditions": {
                "options": { "caseSensitive": true },
                "conditions": [
                  { "leftValue": "={{ $json.result.need_human }}", "rightValue": true, "operator": "boolean" }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "需人工"
            }
          ]
        },
        "options": {}
      },
      "name": "分流",
      "type": "n8n-nodes-base.switch",
      "position": [660, 0]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://glpi.local/apirest.php/Ticket",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            { "name": "Session-Token", "value": "={{ $('取Session').item.json.session_token }}" },
            { "name": "App-Token", "value": "={{ $env.GLPI_APP_TOKEN }}" }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ input: { name: $('规范化输入').item.json.title, content: ($('规范化输入').item.json.content + '\\n\\n【AI 分析】\\n' + ($json.result.solution_steps || []).join('\\n')), type: 1, urgency: $json.result.priority, itilcategories_id: 12 } }) }}",
        "options": {}
      },
      "name": "创建工单",
      "type": "n8n-nodes-base.httpRequest",
      "position": [900, -80]
    },
    {
      "parameters": { "respondWith": "json", "responseBody": "={{ JSON.stringify({ ok: true, ticket: $json.id }) }}" },
      "name": "返回结果",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [1140, -80]
    },
    {
      "parameters": { "respondWith": "json", "responseBody": "={{ JSON.stringify($json.result) }}" },
      "name": "返回人工分诊建议",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [900, 120]
    }
  ],
  "connections": {
    "Webhook": { "main": [[{ "node": "规范化输入", "type": "main", "index": 0 }]] },
    "规范化输入": { "main": [[{ "node": "调 DSH Agent", "type": "main", "index": 0 }]] },
    "调 DSH Agent": { "main": [[{ "node": "分流", "type": "main", "index": 0 }]] },
    "分流": {
      "main": [
        [{ "node": "创建工单", "type": "main", "index": 0 }],
        [{ "node": "返回人工分诊建议", "type": "main", "index": 0 }]
      ]
    },
    "创建工单": { "main": [[{ "node": "返回结果", "type": "main", "index": 0 }]] }
  },
  "settings": { "executionOrder": "v1" }
}
```

GLPI 的 session token 需要前置一个"取 Session"节点（GET `initSession`），这里为简洁省略，实际导入后补上，并在 workflow 里开启 execution timeout 与错误工作流。

### 10.5 分级策略（这张表决定 Agent 会不会"闯祸"）

| 级别 | 典型工单 | Agent 动作 | 人工介入 |
|---|---|---|---|
| **L1 自动闭环** | 密码重置、软件安装指引、打印机脱机、VPN 连不上、共享盘映射 | 检索知识库 → 推送步骤 → 用户确认后自动关单 | 用户点"已解决"即可 |
| **L2 辅助人工** | 网络间歇断连、系统蓝屏、邮箱收发异常 | 建单 + 附诊断方案 + 关联资产 | 一线确认后执行 |
| **L3 直接派单** | 服务器宕机、数据损坏、安全事件、涉及权限变更/数据删除 | 只做信息补全和分类，不出方案 | 立即派二线，禁止 Agent 给操作命令 |

**三条铁律**：
1. **Agent 永不给"删除/格式化/权限提升"类命令**，无论置信度多高（在 prompt 里硬写死，不是靠自觉）。
2. **置信度 < 0.8 一律转人工**，宁可多派一次，不要错关一单。
3. **所有 Agent 输出在 GLPI 里标注 `[AI]` 前缀**，便于事后审计与效果评估。

### 10.6 幂等、去重、可观测

n8n 侧的必做三件事：

```javascript
// 1. 去重：同一资产 + 同一标题 30 分钟内只建一次单
const key = `${asset}|${title}`;
const seen = await $getWorkflowStaticData('global');
if (seen[key] && Date.now() - seen[key] < 30*60*1000) return [];  // 丢弃
seen[key] = Date.now();

// 2. 兜底：Agent 超时或返回非 JSON，走纯规则分类，不能把工单丢了
// 3. 落库：把 run_id、原始 JSON、最终动作写入一张审计表（MySQL / 飞书多维表格都行）
```

审计表建议字段：`run_id / 到达时间 / 来源 / 标题 / AI分类 / 置信度 / 动作 / 工单号 / 人工是否改判 / 处理时长`。
跑一个月后，用"人工改判率"衡量 Agent 质量——这是唯一靠谱的 KPI，不用看模型自评分。

---

## 十一、方向三：二次开发 WebUI，嵌入企业内部平台

### 11.1 先想清楚嵌哪个层级

三种嵌入方式，成本和体验差异很大：

| 方式 | 做法 | 优点 | 代价 | 适用 |
|---|---|---|---|---|
| **A. iframe 嵌入** | 门户里塞一个 iframe 指向 DSH | 半天搞定 | cookie/同源、白标受限、移动端体验差 | 快速试点 |
| **B. 反代 + 子路径** | Nginx 把 DSH 挂到 `portal.com/ai/` | 真正的同一站点，SSO 自然 | 要处理 base path、WebSocket 路径 | 正式上线（推荐） |
| **C. 前端重构** | 用 DSH 的 HTTP/WS 接口自建前端 | 完全白标，深度可控 | 工作量大，要跟着后端版本走 | 已有成熟中台 |

对内部平台，**选 B**。下面按 B 展开，同时给 A 的兼容配置。

### 11.2 白标定制：改什么、在哪改

源码版（`pnpm run build` 之前）改这几处：

```
packages/web/src/
├── theme/tokens.css        # 主色、圆角、字体
├── assets/logo.svg         # 替换为内网 logo
├── layouts/Header.tsx      # 去掉"更新日志/文档/反馈"外链
└── config/index.ts         # 默认模型、默认预设、欢迎语
```

`tokens.css` 最小改动集：

```css
:root {
  --ds-bg: #f7f9fc;
  --ds-surface: #ffffff;
  --ds-border: #dbe3ec;
  --ds-primary: #1f6feb;         /* 企业主色 */
  --ds-text: #1a2332;
  --ds-radius: 10px;
  --ds-font: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
```

必须一并处理的**内网细节**：
- 字体：删掉 Google Fonts / 外部 CDN 引用，改为本地字体栈（上面的 `system-ui` 兜底就够）。
- 图标：确认没有 iconfont 外链。
- 埋点/遥测：搜 `telemetry`、`analytics`、`posthog`、`sentry` 关键词，全部置空或指向内网。
- 更新检查：关掉版本探测请求，否则每次启动都会超时卡顿。

改完：

```bash
DSH_WEB_BASE=/ai pnpm run build     # base path 与反代路径保持一致
```

> base path 是这类集成的头号坑：反代挂在 `/ai/`，但前端资源仍按 `/` 请求，结果整页白屏。构建时和 Nginx 里必须同一个值。

### 11.3 Nginx 反向代理（含 WebSocket 与子路径）

```nginx
server {
    listen 443 ssl;
    server_name portal.company.com;

    ssl_certificate     /etc/ssl/corp.crt;
    ssl_certificate_key /etc/ssl/corp.key;

    # ── SSO：没有企业身份就不放行 ──
    location = /oauth2/auth  { internal; proxy_pass http://127.0.0.1:4180; }
    location = /oauth2/sign_in { proxy_pass http://127.0.0.1:4180; }
    location /oauth2/ { proxy_pass http://127.0.0.1:4180; }

    # ── DSH 子路径 ──
    location /ai/ {
        auth_request /oauth2/auth;
        error_page 401 = /oauth2/sign_in;

        # 把企业身份透传给 DSH，供多租户隔离
        auth_request_set $user  $upstream_http_x_auth_request_user;
        auth_request_set $email $upstream_http_x_auth_request_email;
        proxy_set_header X-Forwarded-User  $user;
        proxy_set_header X-Forwarded-Email $email;

        proxy_pass http://127.0.0.1:3080/;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebSocket（DSH 的流式输出与轨迹推送走这里）
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "upgrade";

        # 本地模型推理慢，读超时一定要放大
        proxy_read_timeout  900s;
        proxy_send_timeout  900s;
        proxy_buffering     off;      # 流式必须关，否则首字延迟巨大

        client_max_body_size 50m;
    }

    # 反代时关掉上游的嵌入限制
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Content-Security-Policy "frame-ancestors 'self'" always;
}
```

三条最容易踩的坑：
1. `proxy_buffering off` — 不加，SSE 流式会攒一坨再吐，看起来像卡死。
2. `proxy_read_timeout` — 默认 60s，本地 14B 跑长任务必定被 504 掐断。
3. `proxy_pass http://127.0.0.1:3080/;` 结尾的斜杠 — 有斜杠才会剥掉 `/ai` 前缀，少了就 404。

### 11.4 SSO 接入（OAuth2 Proxy + 飞书/企业微信）

`oauth2-proxy` 配置（以飞书 OIDC 为例）：

```bash
oauth2-proxy \
  --provider=oidc \
  --oidc-issuer-url=https://accounts.feishu.cn/oidc \
  --client-id=${FEISHU_APP_ID} \
  --client-secret=${FEISHU_APP_SECRET} \
  --redirect-url=https://portal.company.com/oauth2/callback \
  --email-domain=* \
  --cookie-secure=true \
  --cookie-samesite=lax \
  --cookie-expire=24h \
  --upstream=http://127.0.0.1:3080/ \
  --set-xauthrequest=true \
  --reverse-proxy=true
```

> `--cookie-samesite=lax` 是嵌入门户时的关键：`strict` 会让 SSO 回跳后 cookie 不带上，表现为"登录成功但又被弹回登录页"。

**身份映射到工作区隔离**（企业内部多部门共用一套 DSH 必须做）：

在 DSH 的 Auth/中间件插件里按 `X-Forwarded-User` 分配 workspace：

```js
// auth-tenant/index.js
export default class extends Plugin {
  async activate() {
    this.ctx.middleware(async (req, next) => {
      const user = req.headers['x-forwarded-user'];
      if (!user) throw new Error('unauthorized');

      const tenant = user.split('@')[0];
      const wsRoot = `/data/workspace/${tenant}`;

      // 强制覆盖请求里的 workspace，禁止用户自行指定
      req.workspace = wsRoot;
      req.tenant = tenant;              // 供审计/配额使用
      return next();
    });
  }
}
```

要点：**workspace 必须服务端强制指定，绝不能信任前端传的值**——否则 A 部门可以读写 B 部门的文件。

### 11.5 若坚持用 iframe（方式 A）

```nginx
# 门户侧
location /ai-embed/ {
    proxy_pass http://10.0.0.30:3080/;
    proxy_set_header Host $host;
    proxy_hide_header X-Frame-Options;
    add_header Content-Security-Policy "frame-ancestors https://portal.company.com" always;
    proxy_cookie_path / /ai-embed/;
    proxy_cookie_flags ~ nosecuresamesite=none;
}
```

必须同时满足：上游去掉 `X-Frame-Options`、CSP 放行父站、cookie `SameSite=None; Secure`（跨站才带得上）。
移动端和输入法兼容性会打折扣，所以只建议做试点。

### 11.6 完整部署编排

`docker-compose.prod.yml`

```yaml
services:
  dsh:
    image: dsh:corp-1.0          # 白标构建产物
    environment:
      DSH_MODEL: local-coder
      DSH_WEB_BASE: /ai
      DSH_WEB_PORT: "3080"
      DSH_TRUST_PROXY: "1"
      DSH_REQUIRE_USER_HEADER: "X-Forwarded-User"
    volumes:
      - /data/dsh-home:/root/.dsh
      - /data/workspace:/workspace
    restart: unless-stopped

  oauth2-proxy:
    image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
    command: ["--config=/etc/oauth2-proxy.cfg"]
    volumes: ["./oauth2-proxy.cfg:/etc/oauth2-proxy.cfg:ro"]
    restart: unless-stopped

  nginx:
    image: nginx:1.27-alpine
    volumes: ["./nginx.conf:/etc/nginx/conf.d/default.conf:ro"]
    ports: ["443:443"]
    depends_on: [dsh, oauth2-proxy]
    restart: unless-stopped
```

配套的落地顺序（不要一次全上）：

1. 先本机 `dsh web --no-open` 跑通，确认模型可用。
2. 加 Nginx 子路径反代，验证流式输出与 WebSocket 正常。
3. 接 OAuth2 Proxy，验证登录回跳 + cookie 正确。
4. 上租户 middleware，验证 A/B 部门 workspace 互相隔离。
5. 最后做白标与内网资源替换，跑一遍断网检查。
6. 灰度：先给 IT 部门 5 个人用两周，收集"结果不可信/太慢/看不懂"的反馈再放量。

### 11.7 平台化之后必须补的三件事

- **配额**：按 tenant 限制并发会话数与日 token 消耗，否则一个部门把 GPU 占满，全公司排队。
- **审计**：把每次会话的 run_id、用户、workspace、工具调用清单落库。企业环境里"Agent 读了什么文件、跑了什么命令"必须可回溯。
- **熔断**：单次任务超过 N 分钟或工具调用超过 M 次，强制终止并提示用户拆任务——本地小模型最容易陷入死循环。

---

## 十二、三个方向的组合形态（一条完整链路）

把上面三块拼起来，就是一套可以真实落地的内网智能运维中枢：

```
员工在门户（SSO 登录，白标 DSH WebUI）
        │
        ├─ 直接对话：查知识库、写脚本、看日志（本地模型，零外网）
        │
        └─ 提报故障 → n8n Webhook → DSH Bridge 分诊
                                    │
                                    ├─ 检索 GLPI 历史工单 + 本地 knowledge/
                                    ├─ 输出分类/优先级/方案/置信度
                                    ▼
                              GLPI 建单 → 派单/自动闭环
                                    │
                                    ▼
                              结果回写门户 + 企业微信通知
```

全部组件都跑在内网：模型（Ollama/llama.cpp）、Agent 运行时（DSH）、编排（n8n）、工单（GLPI）、入口（Nginx + OAuth2 Proxy）。
唯一的外部依赖只剩你自己的内部身份源，这正是"完全离线"的含义。

---

## 十三、落地优先级建议（给你自己的排期参考）

| 阶段 | 目标 | 工作量 | 验收标准 |
|---|---|---|---|
| P1（1~2 天） | Ollama + 14B 跑通，工具调用验证通过 | 小 | curl 能稳定拿到合法 `tool_calls` |
| P2（2~3 天） | DSH 接本地模型，跑通 3 个真实工程任务 | 小 | 任务成功率 > 80%，不改系统提示 |
| P3（3~5 天） | Bridge + n8n + GLPI 打通，只做"分析不改单" | 中 | 输出 JSON 合法率 > 95%，人工改判率 < 20% |
| P4（3~5 天） | 开放 L1 自动闭环（限 5 类高频问题） | 中 | 零误关单，全部带 `[AI]` 标注 |
| P5（5~8 天） | 白标 + SSO + 租户隔离上线 | 大 | 断网自检全通过，A/B 部门数据不串 |

先把 P1/P2 做扎实。**本地模型的工具调用稳定性是一切的地基**，这一层不稳，后面所有集成都是在沙子上盖楼。

> （注：部分内容由豆包 AI 生成）