Skip to content
kdc307950-artPublic

About

Multi-tenant IT service desk with LangGraph, Agentic RAG, MCP, human approval, audit, and idempotent VPN configuration changes.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

企业 VPN 配置变更安全执行与对账系统

Project overview

Project focus: A multi-tenant IT service desk prototype for VPN fault intake, knowledge-backed recommendations, controlled configuration preview, approval, asynchronous execution, and result reconciliation.

Engineering evidence: LangGraph intake and recovery flows, Agentic RAG with citation gates, MCP tool governance, tenant and permission checks, idempotent writes, PostgreSQL checkpoints, Redis limits, audit trails, and Fake FMG failure-branch drills.

Current boundary: The repository is honest about what is verified locally, in CI, with mocks, and against real external systems. The VPN control plane is a controlled demonstration and does not claim unattended production changes.

冻结版本:vpn-control-v1.0 · 2026-09-05

本项目的核心主线是企业 VPN 配置变更控制,不是通用客服 Demo:通过租户级目标校验、配置 preview、差异哈希审批、FortiManager 异步任务和人工对账,控制 VPN 配置变更的副作用边界。IT 服务台是业务入口,通用聊天和 Supervisor 仅保留为历史示例。

CI

面向中小企业客服、IT、行政支持部门的多租户工单自动处置系统骨架,首个落地形态是内部 IT 服务台(V1 仅 Web 闭环):V1 演示主线只有 it.vpn(VPN 受理与建议闭环)——员工通过 Web 提交 VPN 故障,系统自动分类(it.vpn + vpn_fault)、按租户 IT 策略追问 8 项固定字段、加载对应 SLA、规则派单,Agent 检索知识库生成带引用的建议,客服在响应式工作台上完成接单、处理、回访与关闭;账号/权限、网络降为旁路或转人工。企业微信等渠道代码保留为非 V1 能力,未纳入演示与验收。

技术底座:确定性工单状态机 + LangGraph 受理/补全/分类/派单图 + Agentic RAG 引用门禁,运行在多租户隔离、PostgreSQL Checkpoint、审计、Redis 限流、预算和 Outbox 之上。

V1 产品边界(一句话):面向中小企业内部 IT 服务台,V1 演示主线只有 it.vpn——员工从 Web 报 VPN 故障,系统自动分类(it.vpn + vpn_fault)、按 8 项固定字段补齐、加载 SLA、派单并给出带引用的建议,人工确认后解决关闭;账号/权限、网络作为旁路或转人工保留能力,不再与 it.vpn 并列为主演示。企业微信等渠道保留代码但非 V1 能力。完整范围见 docs/product/vpn-v1-scope.md:目标客户、VPN 受理与建议闭环(主产品 it.vpn)、主链路(Web 闭环)、非目标功能与人工介入规则;验证指标与未验证边界见 docs/evaluation/v1-report.md。

Fortinet VPN 控制面垂直演示扩展

在 it.vpn 受理与建议闭环之外,仓库当前包含一条面向约 50 人中小企业的 Fortinet 控制面演示链路,定位严格收窄为:租户级配置漂移检测/preview → 人工审批 → FMG 异步 task → 对账。它不是单员工 VPN 重签,也不是全自动生产修复系统。

  • 写入动作使用 redeploy_tenant_vpn_config 语义;旧 reissue_config 仅保留兼容,不作为新的业务入口。
  • install 前必须完成 preview;审批绑定 diff_hash,预览结果变化后必须重新审批。
  • FMG task id、提交状态、目标快照、diff 快照与轮询信息落在现有 vpn_reissue_operations,数据库迁移版本为 v25,不新增平行真相表。
  • install 已发出但 task id 未确认时进入 submission_unknown,禁止自动重试;通过 POST /vpn/reissue/{operation_id}/confirm-submission 记录人工核对结果,submitted 只读查询 task,not_submitted 收敛为失败。
  • 已验证:仓库内 Fake FMG 真 HTTPS/HTTP 往返、成功/超时保留 task id/业务拒绝分支、接口权限与跨租户隔离测试。
  • 未验证:真实 FortiManager staging、真实 FortiGate、真实生产写入、trial 许可的固定设备或 ADOM 数量。Fake FMG 证明的是客户端协议与处置逻辑,不证明厂商端实际下发结果。
  • 生产链路不采用 Supervisor 多 Agent;确定性状态机负责审批、状态和副作用边界,Agent 只保留在受理/知识建议等非写入边界。
  • 工单的受控升级入口为 POST /tickets/{ticket_id}/vpn/redeploy-request:仅 it.vpn、in_progress 工单且具备 ticket:agent 权限时可创建租户级重推审批。调用方不能指定目标、用户、资产或幂等键;请求仍必须经过 preview、diff_hash 审批和对账,不能直接 install。

Windows 演示只需执行 ./scripts/demo.ps1:脚本构建并启动 Compose、通过前端代理等待 /api/readyz,并默认运行八步 Fake FMG 演练。打开工作台后选择固定演示身份即可登录,不再需要复制令牌。Fake FMG 和仅供演示的自签名证书均在 tools/fake-fmg/,不再依赖 D:\fmg-vm。仅运行演练可执行 ./scripts/drill-fmg.ps1;停止环境执行 ./scripts/demo.ps1 -Down。脚本默认清空 Docker 配置中可能残留的本地 SOCKS 代理,并优先复用本机已缓存的 uv、Node.js 和 Nginx 镜像;企业代理可通过 DEMO_HTTP_PROXY、DEMO_HTTPS_PROXY、DEMO_ALL_PROXY 传入。

版本冻结与验收清单见 docs/product/vpn-v1-scope.md。冻结后只接受 bug 修复、安全修复和依赖升级;任何业务范围、状态机或外部写入语义变化必须新开版本。

三档验证口径(请勿混淆):

  1. 本地演示:docker compose -f infra/compose.demo.yml up --build -d → 浏览器访问 http://127.0.0.1:8000 → 选择固定演示身份;只验证“能跑通闭环”,不产生评测数字。API 调试仍可单独签发开发令牌。
  2. CI 集成验证:GitHub Actions 启动 PostgreSQL/Redis → 迁移 → 种子 → run_ticket_eval --require-db(90 条真实检索评测)→ 真实仓储生命周期测试(test_ticket_lifecycle_postgres.py)。只有这一档的数字能进 docs/evaluation/v1-report.md。
  3. 未验证(不写“已完成”):真实企业微信自建应用回调解密/消息收发、真实模型生成的引用与 P95/成本、演示视频、生产长期运行。

legacy-demo/ 目录下的 main.py / main_supervisor.py / main_workflow.py 与 workflows/legacy-demo.json 是早期通用聊天/天气/计算 Demo,统一标记为 legacy-demo,仅供试跑图结构,与生产工单链路无关;产品定位与入口见下文。

5 分钟跑起来

需要 Docker Desktop 和首次构建时可访问镜像/依赖仓库的网络。不需要本机装 Python、Postgres 或 Redis;镜像构建完成后可用 -SkipBuild 复用本地镜像。

./scripts/demo.ps1

脚本启动 7 个容器(fake-fmg / postgres / redis / migrate / seed / agent / web),只暴露 127.0.0.1:8000(web/Nginx)。若只需工作台、不运行控制面演练,可执行 ./scripts/demo.ps1 -SkipDrill。就绪后:

docker compose -f infra/compose.demo.yml ps
docker compose -f infra/compose.demo.yml exec agent \
  python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/readyz', timeout=3).status==200 else 1)"

浏览器访问 http://127.0.0.1:8000(Nginx 托管前端,/api/* 去掉 /api 前缀后代理给 agent)。 healthcheck 已保证 agent /readyz、seed 退出码 0、web 首页可达。

没填 DEEPSEEK_API_KEY 也能起来——服务健康、接口可看,只是自动分类和知识建议会失败。想完整演示就在 shell 里 export DEEPSEEK_API_KEY=sk-xxx 再 up。

用完 docker compose -f infra/compose.demo.yml down -v 清干净。这份 compose 里的密码是写死的弱口令且不暴露数据库端口,仅供本地演示,不要用于任何联网环境。

10 分钟演示:跑通 IT 服务台完整闭环

compose 的 seed 服务会自动生成幂等演示数据(租户 demo:SLA、it.vpn 策略、客服团队/成员/排班/路由、8 篇知识文档、5 台资产);容器内重跑:

docker compose -f infra/compose.demo.yml exec agent python -m backend.seed_demo

演示账号固定为 demo 租户下的 customer-1(员工)、agent-1(IT 客服)和 admin-1(IT 管理员)。打开工作台后进入「选择演示身份」,点击对应身份即可获得短期开发会话;前端不接受自定义租户、用户或角色。生产模式使用 OIDC + PKCE,演示脚本与验收检查点见 docs/DEMO_SCRIPT.md:客户提交「VPN 无法连接」→ 自动分类 it.vpn → 追问缺失字段 → 命中 sla-vpn → 派单给 team-it → 知识建议带引用 → 客服接单处理 → 回访关闭。

仓库里的入口

生产路径只有一条:backend/app.py。 本文档其余部分讲的都是它——多租户、审计、预算、限流、工具治理、跨请求人工审批都在这条路径上。

legacy-demo/ 目录下还有三个 CLI 脚本与一个 JSON 工作流(已从根目录迁出,根目录生产入口唯一为 backend/app.py),是早期通用聊天/天气/计算 Demo(legacy-demo),保留用于快速试跑图结构,不是产品入口:

入口 用途 与生产路径的关系
backend/app.py 生产服务,FastAPI + SSE(IT 服务台) 唯一受支持的部署形态
legacy-demo/main.py legacy-demo:单 Agent 命令行对话,带消息摘要 不经过鉴权、审计、限流和预算
legacy-demo/main_supervisor.py legacy-demo:硬编码 supervisor 图 + 命令行审批 审批是进程内 input() 阻塞,不是生产的跨请求审批
legacy-demo/main_workflow.py legacy-demo:从 JSON 加载图 + 命令行审批 用本地 checkpoints.db,加载 legacy-demo/workflows/legacy-demo.json

三个 CLI 的价值是改完 legacy-demo/workflows/*.json 后不起容器就能验证图跑不跑得通。但人工审批的真实实现不在它们里面——CLI 里审批是同一个进程同一次调用中的阻塞输入,生产里是两次 HTTP 请求、两条 SSE 流、状态落在 PostgreSQL checkpoint 上,见 人工审批为什么是两次请求。运行示例:uv run python legacy-demo/main.py(启动时会打印 DEMO ONLY 提示)。

SUPERVISOR_AGENT.md 是同一时期写的说明书(legacy-demo),其中的 HITL 部分已被生产链路取代,只作为图结构和路由逻辑的补充阅读。

工单业务链路

正式工单链路与旧 /chat/* 对话接口独立:

创建工单 → 受理 → 缺字段则 awaiting_customer → 客户类型化恢复
       → 分类 → 规则派单 queued → assigned → in_progress
       → resolved → 满意度回访 → closed

工单快照与 ticket_status_events 使用连续乐观锁版本;批量状态转换在同一个 PostgreSQL 事务中写快照和事件。LangGraph Checkpoint 负责受理节点的暂停/恢复,工单表是业务状态的事实来源。

主要 API:

API Scope 用途
POST /tickets ticket:customer 创建工单
GET /tickets ticket:customer / ticket:agent 游标分页、状态/类别/团队筛选
GET /tickets/{id} 同上 查询工单;客户只能看自己的
POST /tickets/{id}/intake ticket:customer 启动受理图
POST /tickets/{id}/resume ticket:customer 按真实 interrupt ID 补充信息
POST /tickets/{id}/transitions 与 actor 对应的 ticket:* 接单、处理、解决、关闭等
POST /tickets/{id}/survey ticket:agent 发起回访并写 Outbox
POST /tickets/{id}/survey/{survey_id}/response ticket:customer 提交 1–5 分满意度
POST /tickets/{id}/copilot ticket:agent 触发 Resolution Copilot(入队 queued,返回 run_id)
GET /tickets/{id}/copilot/{run_id} ticket:agent 轮询运行状态(queued/processing/completed/failed/dead)
GET /tickets/{id}/copilot/latest ticket:agent 查询工单最新 Copilot 草稿
POST /tickets/{id}/copilot/{draft_id}/approve ticket:agent 审批草稿(仅状态迁移,不发送消息)
GET /admin/copilot/runs security:admin 管理端查看 Copilot 运行

渠道入站采用快速 ACK + 异步 Worker:企微/钉钉 Webhook 与内部通道 POST /integrations/{channel}/events 只做验签解密并登记事件(inbound_events,状态 received),立即返回 202 {"accepted": true, "event_id": ...};run_inbound_worker 领取后异步建单、受理、分类、派单与澄清 Outbox,状态经 received → processing → committed/failed/dead,临时错误指数退避、超限进 dead 可重放。调用方通过 GET /integrations/events/{event_id}(ticket:channel)轮询状态并获取 ticket_id;同 event_id 重复登记幂等,不重复建单。企微事件消息(enter_agent / location 等)验签通过后返回 200 忽略,不登记、不建单。

企业微信 /integrations/wecom/webhook 使用 SHA-1 验签、AES-CBC 解密、CorpID 与重放窗口校验;钉钉 /integrations/dingtalk/webhook 使用时间戳和 HMAC-SHA256。两个厂商端点以服务端配置绑定租户,不信任请求体 tenant。内部适配器也可使用带 ticket:channel scope 的 /integrations/{channel}/events。

知识库采用 Agentic RAG:Agent 可在有界轮次内根据检索结果生成补充查询,所有查询都重复执行 tenant、发布状态、有效期和部门 ACL;全文与向量候选使用 RRF 融合。运行时已装配 AgenticRAGService、KnowledgeAnswerService 和可选 PgVectorRetriever,受理图在派单后调用回答门禁生成建议回复。默认策略是建议回复,搜索耗尽后不会自动发送。没有双路证据、缺少有效引用、高风险或财务类问题都禁止自动回复并转人工。pgvector 为可选真实向量后端,未安装扩展或未配置 embedding 端点时只使用全文并明确记录降级原因。

Solution Copilot(backend/copilot/,解决阶段 Agent):针对「处理/解决」阶段的有界只读 Agent,与受理图(Agent 1)解耦。客服在工单上触发后,ResolutionCopilot 在固定管道内做多轮只读工具调用(search_knowledge / search_assets / 历史工单),汇总证据生成待审批草稿,再经独立答案门禁(引用必须落在工具返回的证据里、敏感类别「财务」强制转人工、置信度阈值)把关;默认 auto_reply=False,只产出草稿、不改变工单状态、不发送客户消息。阶段二采用异步 Worker:POST /tickets/{id}/copilot 只登记 queued 运行并立即返回 run_id,模型执行由 backend/copilot/worker.py 领取,前端轮询 GET /tickets/{id}/copilot/{run_id} 至终态;草稿 approve 只做状态迁移,不触发发送。

架构

flowchart LR
    U[React 客服工作台] --> API[FastAPI]
    CH[企业微信 / 钉钉] --> API
    API --> AUTH[OIDC / 租户令牌 / Webhook 验签]
    AUTH --> TICKET[工单领域状态机]
    TICKET --> INTAKE[受理 / 补全 / 分类 / 派单图]
    INTAKE --> RAG[ACL 检索 / 引用门禁]
    TICKET --> COPILOT[Resolution Copilot<br/>解决阶段只读 Agent]
    COPILOT --> RAG
    API --> CHAT[legacy-demo<br/>旧 Chat / JSON 多 Agent 编排]
    TICKET --> PG[(PostgreSQL<br/>工单 / 事件 / Checkpoint / Outbox)]
    API --> REDIS[(Redis<br/>限流 / 预算 / 撤销)]
    PG --> WORKER[Outbox / SLA / 回访 / Copilot Worker]
    INTAKE --> LLM[DeepSeek / 可替换分类器]
    API -. 指标 / 链路 .-> OTEL[OTel / Prometheus / Jaeger]
Loading

两种图形态共用同一套 checkpointer、store 和工具治理钩子,因此多租户隔离、审计、预算、限流对上层完全一致,差异只在图结构本身。

「Agent / Worker / 确定性流程」边界

这个项目不是多智能体编排(生产链路上没有 supervisor 路由、Agent 互不协作);真正的多 Agent 能力保留在 legacy-demo。生产侧的“智能体”只有两个各自独立、面向不同阶段的专用 Agent,其余大规模并行靠异步 Worker,受理与派单主体是确定性流程。

类别 模块 / 入口 是否用 LLM 触发方式 说明
确定性流程(受理主体) helpdesk/graph.py 除 compose_answer 外的节点 否 受理链路自动 归一化 → 关键词分类 → 策略 → 完整性 → 澄清中断 → 规则派单,纯函数 + 状态机
Agent 1(受理图) helpdesk/graph.py 的 compose_answer_node 是(RAG 拟答) 受理链路末尾 全图唯一走 LLM 的节点:检索知识生成带引用的建议回复
Agent 2(Resolution Copilot) copilot/agent.py 是(有界工具循环) 客服触发,run_copilot_worker 异步执行 解决阶段只读多轮工具调用,产出待审批草稿,过独立答案门禁
Agentic RAG 内部循环 knowledge/agentic.py 是 检索时 单组件内部的有界轮次重写查询,不算独立的“智能体”
状态机 / 规则域 helpdesk/domain.py、tickets/routing.py、tickets/policies.py、tickets/sla.py 否 被上层调用 状态转移、派单路由、租户策略、SLA 计算,纯函数 / 数据访问
异步 Worker inbound_worker / outbox_worker / sla_worker / workflow_recovery / run_copilot_worker 否(只执行,不编排) 常驻进程 / 定时 渠道入站建单受理、消息投递、SLA 违约扫描、恢复、异步跑 Copilot 模型
legacy-demo(多 Agent 编排) legacy-demo/main_supervisor.py、supervisor_agent.py、workflow/ 是 CLI 手动 supervisor 路由 + 子 Agent + interrupt 人工审批,仅试跑图结构,非产品入口

说明:run_*_worker.py 是各 Worker 的进程入口,*_worker.py(inbound_worker / outbox_worker / sla_worker / copilot/worker.py)是常驻实现;它们负责把渠道事件与模型执行异步化(FOR UPDATE SKIP LOCKED + 租约 + 指数退避 + dead 重放),与“是否多 Agent”是两个维度。

人工审批为什么是两次请求

SSE 是服务端单向推送,而审批本质是双向的。一次审批被拆成两次 HTTP 请求、两条 SSE 流、两个 run_id,绑在同一个 thread 上:

sequenceDiagram
    participant F as 前端
    participant B as 后端
    participant P as PostgreSQL
    F->>B: POST /chat/stream
    B-->>F: data: {"type":"text", ...}
    B->>P: checkpoint 落盘 + 审计 interrupt_raised
    B-->>F: data: {"type":"interrupt", "interrupt_id": "..."}
    Note over B,F: 流在此结束,刻意不发 end<br/>本轮审计状态 awaiting_approval
    Note over F: 输入框锁定,展示审批卡片
    F->>B: POST /chat/resume {approved, interrupt_id}
    B->>P: 读状态校验挂起 → Command(resume=...)
    B-->>F: data: {"type":"end", "run_id": "..."}
Loading

前端靠 end 事件的缺席区分「答完了」和「等你批」,状态机因此少一个变量。挂起的那一轮审计状态是 awaiting_approval 而不是 completed——一次挂起的运行没有完成,记成完成会让运行成功率指标失真。

编排模式与人工审批(legacy-demo)

以下 workflow 图形态与 /chat/* 审批链路是早期通用聊天 Demo 的遗留能力(legacy-demo),与生产 IT 服务台工单链路无关。工单受理图由 src/my_agent/helpdesk/graph.py 定义,不依赖 AGENT_GRAPH_MODE。

后端仍保留两种图形态,由 AGENT_GRAPH_MODE 切换,默认 single:

AGENT_GRAPH_MODE=workflow
AGENT_WORKFLOW_PATH=legacy-demo/workflows/legacy-demo.json

workflow 模式由 JSON 定义编译出图,支持 supervisor 路由、子 Agent 和 human_approval 审批节点。配置缺失或 spec 不合法时服务在启动阶段直接失败,不会带病启动。示例图 legacy-demo/workflows/legacy-demo.json 是天气/计算路由,仅用于试跑 JSON 编译层。

图停在审批节点时,POST /chat/stream 的 SSE 流下发一条 interrupt 事件并结束,不发 end:

{"type": "interrupt", "run_id": "...", "thread_id": "...", "interrupt_id": "...", "question": "是否批准将问题交给 weather 处理?"}

审批人调用 POST /chat/resume 恢复执行,请求体为 {"thread_id": "...", "approved": true, "interrupt_id": "...", "resumed_from": "..."}。该端点要求 chat:approve scope(与 chat:write 分离——能发消息不等于能替租户批准操作),并同样受限流和租户预算约束,因为恢复执行会真实触发模型调用。服务端先读取会话状态确认存在挂起审批,interrupt_id 不匹配或没有挂起审批时返回 409(不是 404,避免通过状态码探测他人 thread_id 是否存在),以此防重复审批和跨会话串批。恢复轮开新的 run_id,审计 metadata 记录 resumed_from、approved、interrupt_id 和 approver_user_id。

回传给前端的始终是逻辑 thread_id,不是内部的 tenant:user:thread 物理命名空间。

安全配置

.env 只用于本地环境,禁止提交。工作区 .env 中的真实 DeepSeek 和 LangSmith 密钥应在对应平台撤销并重新生成。

复制 .env.example 为 .env,填写:

  • DEEPSEEK_API_KEY:后端调用模型所需。
  • TENANT_TOKEN_SECRET:签发和验证租户令牌的服务端密钥;生产建议替换为 OIDC/JWT 验证。
  • LANGCHAIN_API_KEY:可选,仅用于 LangSmith 追踪。
  • WECOM_TENANT_ID/TOKEN/ENCODING_AES_KEY/CORP_ID:企业微信 Webhook,四项必须成组配置。
  • DINGTALK_TENANT_ID/APP_SECRET:钉钉 Webhook,两项必须成组配置。
  • WEBHOOK_REPLAY_WINDOW_SECONDS:Webhook 时间戳容差,默认 300 秒。

启动后,POST /chat/stream 必须带 Bearer 令牌。开发环境使用 AUTH_MODE=dev 的内部签名令牌;公网环境必须切换为 AUTH_MODE=oidc,校验 issuer、audience、过期时间、scope 和撤销状态。服务端会将客户端会话转换为 tenant_id:user_id:client_thread_id。GET /health 保持公开,便于健康检查。限流默认使用 Redis,按租户和用户共享计数;Redis 不可用时默认 fail-closed。

OIDC 生产模式要求 issuer、audience、JWKS、Redis 撤销、jti 和 token 年龄校验。具备 security:admin scope 的内部管理令牌可调用 POST /admin/oidc/revoke,提交 jti 和 expires_at;普通用户令牌不能调用该接口。公网上线前必须将 AUTH_MODE=oidc,禁止使用内部 dev token。

工具调用经过统一治理层:按服务端租户身份检查 scope 和工具白名单,限制输入长度,执行单工具超时,临时错误仅对无副作用工具重试。工具审计只保存工具名、状态、耗时和错误类型等摘要,不保存完整 prompt、Authorization、API key 或原始工具结果。

需要限制租户工具集合时设置 TOOL_TENANT_ALLOWLIST=tenant-a=calculate,get_weather,search_knowledge;tenant-b=calculate,search_assets;启用该配置后,未列出的租户默认不能调用任何工具。生产工具策略还包含 search_assets、search_knowledge 和副作用工具 send_message,分别要求客服 scope,并对后者关闭自动重试。

工具调用与治理

项目把「能暴露给 LLM 的能力」定义为工具(@tool + model.bind_tools(...)),生产侧共 7 个工具、分属 3 个集合,并全部统一经过 ToolGovernance 治理层放行。下面三张表分别列出工具清单、治理策略与工具集合(profile)。

工具清单

工具 参数 类型 所属集合 / 用途
search_assets query?、owner_user_id?、limit 只读 搜索当前租户 IT 资产(按 hostname/名称/部门/型号/编号)
search_knowledge query、limit 只读 检索知识库,返回 {content, evidence}(引用白名单唯一来源)
get_ticket_history requester_id、limit 只读 查客户历史工单(解决阶段汇总上下文)
get_ticket_messages ticket_id、limit 只读 查工单消息流(解决阶段汇总上下文)
send_message ticket_id、content 副作用 写入 Outbox 异步投递渠道消息(唯一带副作用的工具)
get_weather location 只读 legacy:查 Open-Meteo 实时天气
calculate expression 只读 legacy:AST 白名单安全数学计算

说明:工具实现分两处——src/my_agent/helpdesk/tools.py(HELPDESK_TOOLS / RESOLUTION_COPILOT_TOOLS)与 backend/copilot/tools.py(COPILOT_TOOLS,search_knowledge 走统一 KnowledgeRetriever,附带 retrieval_mode/degraded)。backend/knowledge/agentic.py 的 Agentic RAG 改写查询属于检索组件内部逻辑,不是 function calling。

治理策略(backend/tool_governance.py)

工具 所需 scope 超时 输入上限 可重试 副作用
search_assets ticket:agent 3s 512 是 否
search_knowledge ticket:agent 5s 1024 是 否
get_ticket_history ticket:agent 3s 512 是 否
get_ticket_messages ticket:agent 3s 512 是 否
send_message ticket:agent 5s 4096 否 是
get_weather chat:write 5s 128 是 否
calculate chat:write 2s 512 否 否

工具集合(profile 白名单)

集合 允许的工具 用途
INTAKE_AGENT_TOOLS search_knowledge、search_assets 受理阶段(Agent 1)
RESOLUTION_COPILOT_TOOLS search_knowledge、search_assets、get_ticket_history、get_ticket_messages 解决阶段(Agent 2),全部只读
HUMAN_ACTION_TOOLS send_message 需人工介入的副作用动作

运行期 RunContext.allowed_tools 注入上述集合,ToolGovernance.awrap_tool_call(挂在每个 ToolNode 上)按序执行:scope 校验 → allowed_tools/租户 TOOL_TENANT_ALLOWLIST 子集校验 → 输入长度裁剪 → 单工具超时;仅 retryable=True 且非副作用的工具才自动重试临时错误;审计只记工具名/状态/耗时/错误码,不落 prompt、Authorization、API key 或原始工具结果。

本地开发(不走容器)

先启动 PostgreSQL 和 Redis,再初始化 checkpoint/store/审计表:

docker compose -f infra/compose.dev.yml up -d
uv run python -m backend.migrations

infra/compose.dev.yml 是开发依赖栈,除 Postgres/Redis 外还含 OTel Collector、Prometheus、Jaeger、Grafana,应用本身跑在宿主机上;与之相对,infra/compose.demo.yml 把应用也放进容器,用于一键演示。

应用启动时默认不会自动建表;只有本地临时环境才建议设置 LANGGRAPH_AUTO_SETUP=true。

后端:

uv run uvicorn backend.app:app --host 127.0.0.1 --port 8000 --loop backend.uvicorn_loop:selector_event_loop_factory

--loop 只在 Windows 上需要:psycopg 的异步实现要求 SelectorEventLoop。Linux 容器里用默认循环即可,所以 Dockerfile 的 CMD 没有这个参数。

前端:

cd frontend
npm run dev

先签发本地开发令牌,再写入项目根 .env 的 DEV_TENANT_TOKEN:

uv run python -m backend.issue_dev_token tenant-a agent-1 --role helpdesk-agent

可选角色:chat、helpdesk-agent、helpdesk-customer、helpdesk-channel、helpdesk-approver。Vite 开发代理把 /api 转发到后端,并从项目根 .env 读取 DEV_TENANT_TOKEN 注入 Bearer 头;该令牌不会打进 React bundle。客服工作台需要 ticket:agent,客户建单/补充/回访需要 ticket:customer,渠道内部适配器需要 ticket:channel。开发时可按角色签发不同令牌;公网部署必须改为 OIDC/JWT、撤销机制和共享限流。

迁移与数据

部署或 CI 必须先执行 uv run python -m backend.migrations;应用启动只检查 schema 是否存在,不在多 Worker 中自动迁移。迁移命令使用 PostgreSQL advisory lock,多实例并发执行时只有一个会真正建表。

持锁连接必须是 autocommit 的。psycopg3 默认在第一条语句上隐式开启事务,那条连接会停在 idle in transaction 并持有锁;而 LangGraph 的 checkpointer.setup() 里有 CREATE INDEX CONCURRENTLY,它必须等待所有并发事务结束——等的正是持锁那条连接,迁移直接死锁。这个死锁只在全新空库上出现(已有索引时 IF NOT EXISTS 立刻返回),也就是只在首次部署和干净 CI 环境里触发。见 backend/migrations.py 的注释。

每次 Agent run 都会在 agent_runs / agent_events 中记录运行状态和脱敏事件。运行状态可通过带 chat:read scope 的令牌查询:GET /audit/runs/{run_id}。跨租户查询返回 404,不泄露其他租户记录。

审计记录由独立 retention 任务按 AUDIT_RETENTION_DAYS 清理,默认关闭以避免误删。启用前先用 dry-run 评估范围,再由单独的 Cron/容器任务执行:

$env:AUDIT_RETENTION_ENABLED="true"
uv run python -m backend.retention --dry-run
uv run python -m backend.retention

任务使用 advisory lock,多个实例同时运行时只有一个会执行;每批最多删除 AUDIT_RETENTION_BATCH_SIZE 个已结束 run,并受 AUDIT_RETENTION_MAX_RUNTIME_SECONDS 限制。status=running 或没有 finished_at 的记录不会被删除。清理失败不会回退到内存,也不会影响 Agent 请求;调度器应对非零退出码告警。

Outbox 使用独立常驻进程发送渠道消息、回访和 SLA 升级事件。Worker 通过 FOR UPDATE SKIP LOCKED 支持多副本,暂时性网络错误指数退避,超过最大次数进入 dead:

$env:OUTBOX_SHARED_SECRET="replace-with-service-to-service-secret"
$env:OUTBOX_TICKET_MESSAGE_ENDPOINT="https://internal.example/messages"
$env:OUTBOX_SURVEY_ENDPOINT="https://internal.example/surveys"
$env:OUTBOX_SLA_ENDPOINT="https://internal.example/sla"
uv run python -m backend.run_outbox_worker --poll-interval 1 --batch-size 20
uv run python -m backend.run_sla_worker --interval 30 --batch-size 100
uv run python -m backend.run_workflow_recovery --interval 30 --grace 30

每个 Endpoint 都必须实现 X-Idempotency-Key 幂等语义;Worker 本身只保证数据库领取和状态转移,不替渠道服务解决重复请求。

项目根的 checkpoints.db 是早期 SQLite 实现留下的历史数据,不是运行时的降级后备——backend/runtime.py 只支持 PostgreSQL。迁移旧数据:

uv run python -m backend.migrate_sqlite

迁移前必须设置 MIGRATION_TENANT_ID 和 MIGRATION_USER_ID,旧线程会被导入为该租户用户的线程,避免迁移后出现不可见历史。验证重启恢复正常前,不要删除旧的 SQLite 文件。

可观测性

运行指标通过 OpenTelemetry SDK 生成,并提供 Prometheus scrape endpoint:GET /metrics。生产环境必须设置 METRICS_AUTH_TOKEN,OTLP 导出可通过 OTEL_EXPORTER_OTLP_ENDPOINT 开启。指标标签只包含 route、status、outcome、tool 等低基数字段,不包含 tenant、user、run_id、prompt 或 token。

本地观测栈可通过 infra/compose.dev.yml 启动 OTel Collector、Prometheus、Jaeger 和 Grafana。生产环境应将它们分开部署并配置认证;不要把开发 compose 直接暴露到公网。

MODEL_INPUT_COST_PER_1K_USD、MODEL_OUTPUT_COST_PER_1K_USD 用于将供应商 usage metadata 统一换算为成本。设置 TENANT_DAILY_BUDGET_USD 后,Redis 会按 UTC 自然日对租户做共享预算计数,超过预算的运行返回 budget_exceeded;价格变更时必须同步配置并记录版本。这三个值默认为 0,成本统计因此恒为 0,接入真实供应商价格后才有意义。

部署

Dockerfile 是两阶段构建:builder 用 uv 按 uv.lock 装依赖,runtime 只带虚拟环境和源码,以 uid 10001 的非 root 用户运行。入口不用 uv run——uv 每次启动会校验并可能改写 .venv,而生产容器应跑在只读文件系统上。

docker build -t langgraph-agent:local .

构建时 uv 二进制默认从 ghcr.io/astral-sh/uv:0.11 获取;ghcr 网络受限时用镜像站覆盖:

docker build --build-arg UV_IMAGE=ghcr.nju.edu.cn/astral-sh/uv:0.11 -t langgraph-agent:local .

CI(GitHub Actions)镜像加速采用显式配置,不硬编码公共第三方站:

  • GitHub-hosted runner:运行在海外,docker.io / ghcr.io 默认可达,无需额外配置。
  • 自建 runner(国内网络):
    1. 在 runner 主机配置 ~/.docker/daemon.json 的 registry-mirrors,写入受控可信的 Docker Hub 镜像站后重启 Docker,供 service containers 和构建基础镜像拉取使用。
    2. 在仓库 Settings → Variables 配置 UV_IMAGE 指向可信 ghcr 镜像站(例如 ghcr.nju.edu.cn/astral-sh/uv:0.11),CI 构建步骤会自动作为 --build-arg 传入;未配置时默认使用官方 ghcr.io/astral-sh/uv:0.11。

公网部署参考 infra/k8s/agent-deployment.yaml 与 infra/gateway/nginx.conf:TLS/WAF/JWT 粗校验应放在云 API Gateway 或 WAF,应用仍必须做完整 OIDC、scope、tenant 和撤销校验。Kubernetes Secret 只是接口示例,生产应由云 secrets manager 或 External Secrets 控制器注入,不要把真实密钥写入 YAML。YAML 里的 image: ghcr.io/your-org/langgraph-agent:REPLACE 需要替换成实际镜像仓库地址。

Readiness 与恢复演练

/livez 只表示进程存活;/readyz 会检查 Agent、PostgreSQL schema/version、Redis,以及 OIDC 模式下的 JWKS。生产负载均衡器应只把流量转发给 readyz=200 的实例。容器的 HEALTHCHECK 探的是 /livez 而不是 /readyz:依赖不可用时应该摘流量,而不是重启进程。

uv run python -m backend.backup_restore backup --output .\artifacts\langgraph.dump
uv run python -m backend.backup_restore restore --backup .\artifacts\langgraph.dump --database-url postgresql://langgraph:password@127.0.0.1:5433/langgraph_restore
uv run python -m backend.backup_restore verify --database-url postgresql://langgraph:password@127.0.0.1:5433/langgraph_restore --tenant-id tenant-a --user-id user-1 --thread-id recovery-1

verify 只验证恢复后的 checkpoint 和长期记忆记录;必须再运行一次受保护的真实模型 E2E,验证同一 tenant:user:thread 能继续对话。备份文件和恢复数据库不得使用生产明文密钥或暴露公网端口。

检索评测(量化结果)

内置 52 条脱敏 IT 检索评测集(backend/knowledge/eval_cases.py,覆盖 8 个 IT 子分类 + 跨文档 + 口语改写用例)。在已导入知识库的数据库上运行评测:

# 准备:迁移 + 导入脱敏 IT 知识库(幂等)
uv run python -m backend.seed_demo
# 全文检索基线(未配置 embedding 时自动降级为 lexical-only)
uv run python -m backend.run_knowledge_eval
# 配置真实 embedding 服务后跑 hybrid(KNOWLEDGE_EMBEDDING_ENDPOINT)
uv run python -m backend.run_knowledge_eval --embed

输出量化报告:Top1 命中率 / Recall@k / MRR@k,按分类分项;并统计「无检索命中」用例数——这些用例在受理链路中触发门禁转人工,不会自动发送。--topk 5 --seed --limit N 可调参。配置 embedding 服务:KNOWLEDGE_EMBEDDING_MODEL / KNOWLEDGE_EMBEDDING_DIMENSION / KNOWLEDGE_EMBEDDING_ENDPOINT(POST {"texts": [...]} 返回 {"embeddings": [...]}),并先执行 uv run python -m backend.vector_migrations 与 embedding 导入流水线。

中文分词全文检索(lexical-only 基线)

全文检索使用 jieba + 自定义 IT 词典(backend/knowledge/tokenizer.py)在入库与查询两侧做同一分词,search_text 存分词文本,search_vector 基于分词结果生成(schema v12),pg_trgm 提供错别字/短词兜底。查询侧为三路召回:plainto_tsquery(AND 精确)∪ to_tsquery(OR 覆盖率)∪ trigram 相似度,排序按命中 token 数(子串匹配,对中文分词上下文不一致鲁棒)→ ts_rank → 相似度。所有召回均强制 tenant / published / 有效期 / visibility / 部门 ACL 过滤。

当前内置 52 条评测集(8 个 IT 子分类 + 跨文档 + 口语改写)在演示数据上的 lexical-only 基线(2026-08-28 实测):Top1 98.1%(51/52)、Recall@5 100%、MRR@5 0.990、无命中 0 条。分项中 it.account Top1 为 83.3%(6 中 1 条口语改写未排到首位,属语义改写场景,需 embedding 提升,不代表实现错误)。冻结的 hybrid_holdout@2026-08-30-v1 已通过受保护 CI 验证:Top1 85.7%、Recall@5 96.4%、MRR@5 0.929;生产默认仍走 lexical-only,不能外推演示库指标。

渠道能力(企业微信/钉钉,非 V1)

验签/解密/幂等/追问/门禁的自动化测试覆盖见 tests/test_channel_adapters.py 与 tests/test_ticket_api.py(test_wecom_webhook_*、test_dingtalk_webhook_*)。真实沙箱端到端验证步骤、验证矩阵与验收清单见 docs/CHANNEL_SANDBOX.md。该能力保留代码但不在 V1 演示/验收范围:本地演示走 Web 闭环,渠道闭环未执行 Docker 真链验证,简历表述按“Mock/单测已覆盖,真实渠道未验证”。

测试

uv run pytest tests -q -m "not live_e2e"

需要 PostgreSQL 和 Redis 的集成测试通过 TEST_DATABASE_URL / REDIS_URL 开关。没配这两个变量时它们会被 skip;配了但服务连不上会 fail(每个用例要等满连接超时,整套会从 9 秒变成 6 分钟)。

本地使用轻量集成测试栈跑全套(使用非标准端口,避开已有服务):

docker compose -f infra/compose.test.yml up -d --wait
$env:TEST_DATABASE_URL="postgresql://langgraph:integration_only_not_a_secret@127.0.0.1:55436/langgraph"
$env:DATABASE_URL=$env:TEST_DATABASE_URL
$env:REDIS_URL="redis://127.0.0.1:56379/0"
uv run python -m backend.migrations
$env:KNOWLEDGE_EMBEDDING_DIMENSION="1536"
uv run python -m backend.vector_migrations
uv run pytest tests -q -m "not live_e2e"

跑完使用 docker compose -f infra/compose.test.yml down -v 清理。该栈只有 PostgreSQL 和 Redis,数据放在临时文件系统中,仅用于本地集成测试。

命令行的环境变量优先于 .env(conftest.py 的 load_dotenv() 不覆盖已存在的变量),所以不必改本地配置。

CI 使用 pgvector PostgreSQL 17 / Redis 7 service containers;当 CI=true 时缺少这两个变量会直接失败,不会静默跳过。2026-09-10 在同等 PostgreSQL/Redis 环境完成 800 passed, 4 skipped;真实仓储闭环以 tests/test_ticket_lifecycle_postgres.py 为准。test_ticket_api.py::test_full_lifecycle_http_regression_vpn 为 HTTP 路由回归(Fake runtime);V1 固定 90 条工单评测只有 CI 的 run_ticket_eval --require-db 真实检索结果才能进入 docs/evaluation/v1-report.md。

真实 DeepSeek E2E 默认不运行,以免普通 CI 产生费用。手动 workflow Live Agent E2E 需要受保护环境中的 DEEPSEEK_API_KEY 和 TENANT_TOKEN_SECRET,覆盖文本 SSE、工具调用和同线程续聊。工作流会在运行器内生成 15 分钟有效的测试 Bearer token,避免长期保存会过期的开发令牌。

受保护手动 workflow 配置

以下配置只写入 GitHub Actions,不写入仓库、.env.example、日志或截图。当前主 CI 已验证;三条手动 workflow 只有配置好外部服务后才应触发。

Workflow / GitHub Environment GitHub Secrets GitHub Variables 说明
Live Agent E2E / live-e2e DEEPSEEK_API_KEY、TENANT_TOKEN_SECRET 可选:LLM_BASE_URL、LLM_MODEL 两项缺一会在启动服务前失败;工作流使用 TENANT_TOKEN_SECRET 在运行器内生成仅供本次测试使用的短期 Bearer token。
OIDC Staging Agent E2E / oidc-staging DEEPSEEK_API_KEY、OIDC_STAGING_TOKEN OIDC_ISSUER_URL、OIDC_AUDIENCE、OIDC_JWKS_URL;可选 OIDC_TENANT_CLAIM OIDC token 的 issuer、audience、签名密钥及 scope 必须与变量一致。
Hybrid Eval(手动) / 仓库级 KNOWLEDGE_EMBEDDING_ENDPOINT;可选 KNOWLEDGE_EMBEDDING_TOKEN、KNOWLEDGE_EMBEDDING_MODEL、KNOWLEDGE_EMBEDDING_DIMENSION 无 endpoint 必须是 GitHub-hosted runner 可公开解析和访问的 http(s) 地址;私网/本机地址不可用。维度必须与模型输出及 pgvector 列一致。

若 Hybrid 在 warm-up 前失败并提示 DNS preflight,不是检索指标失败,而是 endpoint URL、DNS 记录或公网可达性有问题。先用同一个公网 URL 验证 POST {"texts":["warmup"]} 的响应契约,再重新触发工作流。完整操作见 docs/HYBRID_EVAL_RUNBOOK.md。

当前边界

明确没做的,不是遗漏而是取舍:

项 说明
工作流存库 + 热编译 改 JSON 工作流仍需重启服务,所有租户共用一份定义
挂起任务 TTL 工单信息补全和旧审批尚未实现自动过期/取消策略
Embedding 供应商 pgvector、HNSW、入库流水线和 Agentic RAG 已实现;演示库 hybrid holdout 已完成受保护 CI 评测,生产仍需小范围观察效果、P95、成本与降级率,默认建议回复而不自动发送
附件安全链路 尚未接对象存储、病毒扫描、临时授权下载和内容解析隔离
PostgreSQL RLS Repository 全部强制 tenant 条件,但数据库行级安全尚未启用
工作台认证 本地演示选择固定身份并由后端签发短期会话;OIDC 模式使用 PKCE,生产按 IdP 主体分配 scope
企微追问闭环(非 V1) 企业微信文本消息建单并追问、恢复受理等代码保留(ticket_customer_pending_intake),但未纳入 V1 演示与验收;本地演示仅 Web 闭环,真实渠道未做 Docker 真链验证
成本统计 单价配置默认为 0,接入真实供应商价格前,成本与预算功能不产生实际数值

About

Multi-tenant IT service desk with LangGraph, Agentic RAG, MCP, human approval, audit, and idempotent VPN configuration changes.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages