docs(架构改进): 补齐 PRD/开发计划/TODO/交接文档,落地无密钥告警与 Redis 分布式锁
一、流程文档(按 AIcoding 六步落地,供新会话从交接文档开工) - 新增 docs/PRD/PRD-架构改进与稳定性加固.md:6 条 FR(文档勘误、非缺陷说明、 无密钥启动告警、审计失败告警、Redis 分布式锁、中间件顺序测试) - 新增 docs/项目框架设计/改进方案评审-问题清单与对比.md:24 项问题分档 A~G, 经两轮独立 AI 评审,无阻断级错误 - 新增 docs/项目框架设计/开发计划-架构改进.md:HOW 层设计,含合并前只做低风险 11 项的批次策略 - 新增 docs/项目框架设计/TODO-架构改进.md:T-101~T-109、T-201~T-202 可勾选项 - 新增 docs/交接文档-架构改进.md:自包含交接入口,hy3 新会话可直接开工 - 新增 docs/项目框架设计/架构设计说明书.md:按模块/分层逐一讲解的全量架构说明 二、代码改动(T-107/108/109、T-201.1、T-201.2) - app/main.py:启动时 DEEPSEEK_API_KEY 缺失告警,明确告知将走降级回复 - app/utils/authz.py:越权审计失败日志补 trace_id,便于串联全链路 - app/api/audit_middleware.py:审计失败日志补 status/path/request_id - app/service/risk/redis_gateway.py:新增 acquire_lock(SET NX EX)与 release_lock(Lua 原子释放,只删自己的锁) - app/service/risk/locks.py:run_locked 改为双层锁,Redis 为主、进程内锁为备; Redis 超时沿用 fn(locked=False) 降级语义,Redis 不可用(含测试 Fake 缺方法的 AttributeError)安全退回进程内锁,绝不抛异常 三、文档勘误(A1/A2/A3) - MEMORY.md:文件数 42→45、Tools 4→5 - 02-mysql-agent专用.sql:会话表 5→6 - 架构设计-风控模块.md:同步更正 四、测试 - 新增 tests/test_locks_redis.py:覆盖抢锁成功、占用超时、Redis 故障降级、 Fake 缺方法降级、只删自己锁、三处调用点 key 前缀 - tests/test_audit_middleware.py:补充告警字段断言 - 全量 pytest 510 passed(原基线 503)
This commit is contained in:
@@ -0,0 +1,682 @@
|
||||
# XingHuo 智能财富管家 · 架构设计说明书
|
||||
|
||||
> **代码基线**:分支 `risk-control-agent`,HEAD `2d0e2fa`(2026-09-08),全量 pytest 503 绿。
|
||||
> **编写原则**:本文所有论断均落到具体文件、类、函数、行号;凡文档口径与代码实测不一致处,以代码为准并在 §9.4 列出。
|
||||
> **与既有文档的关系**:`FRAMEWORK.md` 是选型与状态的速查表,`FLOW.md` 是端到端链路与 bootstrap,本文是**设计决策与理由的系统阐述**——回答"为什么是这样"以及"换成别的会怎样"。
|
||||
|
||||
---
|
||||
|
||||
## 0. 阅读指南
|
||||
|
||||
| 你想知道 | 直接看 |
|
||||
| --- | --- |
|
||||
| 系统整体长什么样 | §1 |
|
||||
| 一次请求从头到尾怎么走 | §2 |
|
||||
| 某个目录/层是干什么的 | §3 |
|
||||
| 某个核心机制怎么实现、为什么 | §4(按子系统分) |
|
||||
| 模块之间怎么协作、往哪扩展 | §5 |
|
||||
| 设计取舍与替代方案代价 | §6 |
|
||||
| 这套设计在什么前提下成立、哪里会崩 | §7 |
|
||||
| 有多种做法时代码选了哪种 | §8 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 系统全景
|
||||
|
||||
### 1.1 一句话骨架
|
||||
|
||||
```
|
||||
Gateway 鉴权 → FastAPI api → service(LangGraph 编排)→ tool / repository → MySQL 双库 / Redis / Milvus / Neo4j → 审计落库
|
||||
```
|
||||
|
||||
展开成进程内分层(`app/`):
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ main.py 装配:lifespan + 中间件栈 + 路由 + 错误 handler │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ api/ 路由 · 鉴权工厂 · 中间件(薄,无业务逻辑) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ service/ 编排与业务内核(LangGraph · Tool · 风控引擎) │
|
||||
├──────────────┬───────────────────────────────────────────────┤
|
||||
│ tool/ │ 纯查询适配器(无业务流程,注册表白名单) │
|
||||
│ repository/ │ 数据访问(core_ro 只读 / session / risk) │
|
||||
│ gateway/ │ 模拟外部交易系统(唯一允许写 Core 的例外) │
|
||||
├──────────────┴───────────────────────────────────────────────┤
|
||||
│ model/(占位) config/ utils/(trace·db·response·authz) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 技术选型与硬约束
|
||||
|
||||
| 维度 | 选型 | 约束来源 |
|
||||
| --- | --- | --- |
|
||||
| 运行时 | Python 3.13.14 + FastAPI | `FRAMEWORK.md` §1 已定 |
|
||||
| Agent 编排 | LangGraph 1.2.x + langchain-openai | 同上 |
|
||||
| 关系库 | MySQL 8.0,**双库** `jinrong_agent` + `jinrong_core` | 同上 |
|
||||
| 缓存 | Redis 8.10.1 | 同上 |
|
||||
| 向量库 | Milvus Lite + pymilvus 3.0.1,1024 维 | 内存 15.4GB 的妥协 |
|
||||
| Embedding | Ollama bge-m3(本地,不出内网) | 数据合规 |
|
||||
| 生成 | DeepSeek API | — |
|
||||
|
||||
**五条不可触碰的红线**(`MEMORY.md` §3,代码层面均有落点):
|
||||
|
||||
1. Core 正式 C1~C5 不可被画像覆盖 → 靠双库物理隔离 + `core_ro` 纯 SELECT 约定(§4.6.1)
|
||||
2. 审计表只 INSERT → 仓储层只暴露 `insert_audit_log` / `insert_input_guard_log`(§4.6.4)
|
||||
3. 代理人草稿不外发客户 → 无外发通道
|
||||
4. 不自动冻户、不自动改风险等级 → 引擎无写 `core_customer_risk` 的路径
|
||||
5. **仅 R-02 适当性可阻断交易** → 代码级物理隔离,见 §4.3.3
|
||||
|
||||
### 1.3 双库与数据分层
|
||||
|
||||
| 层 | 存储 | 内容 | 谁写 |
|
||||
| --- | --- | --- | --- |
|
||||
| L0 | `jinrong_core` | 客户主档、风评、持仓、流水、交易 | 只读(唯一例外见下) |
|
||||
| L3 | `jinrong_agent` | 风控自有监测层 `customer_profile_l3` | 风控引擎 |
|
||||
| 会话 | Redis + `jinrong_agent` | 窗口缓存 + 永久消息 | 对话链路 |
|
||||
| RAG | Milvus + `data/kb/` | `kb_product_rules` 产品规则 | `scripts/kb/build_kb.py` |
|
||||
| 关系 | Neo4j | 客户-产品-代理人图谱 | `scripts/sync/sync_neo4j.py` |
|
||||
|
||||
**表规模**:`jinrong_core` 12 张 + `jinrong_agent` 17 张(共用 11 + Agent 专用 6)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 请求生命周期
|
||||
|
||||
### 2.1 中间件栈:实际执行顺序及其推导
|
||||
|
||||
`app/main.py` 的注册序是 **audit(第 81 行)在前、trace(第 87 行)在后**。
|
||||
|
||||
Starlette 的 `add_middleware()` 内部执行 `self.user_middleware.insert(0, ...)`,而 `build_middleware_stack()` 用 `reversed(middleware)` 由内向外包裹——**数组中越靠前越外层**。
|
||||
|
||||
因此实际栈序(外 → 内):
|
||||
|
||||
```
|
||||
ServerErrorMiddleware → trace_middleware → audit_middleware → ExceptionMiddleware → 路由
|
||||
```
|
||||
|
||||
**trace 在外层先执行,audit 在内层后执行**。
|
||||
|
||||
代码自证:`audit_middleware.py:9` 注释写"注册顺序:trace 中间件之后注册(即执行序在 trace 之内),保证审计时 trace_id/request_id 已绑定"。
|
||||
|
||||
> ⚠️ **这是一处隐式依赖**:audit 的正确性完全依赖注册顺序,而顺序由"谁写在文件后面"决定。目前靠注释固化(两处都有),但**没有测试守卫**——若有人调换两个装饰器的位置,审计会静默丢失 trace_id,且不报错。
|
||||
|
||||
### 2.2 四个 ID,各管一层
|
||||
|
||||
| ID | 层级 | 生成 | 稳定性 |
|
||||
| --- | --- | --- | --- |
|
||||
| `actor_id` | 人 | JWT `claims.sub`(`deps.py:189`) | 跨请求永不变 |
|
||||
| `session_id` | 一次聊天 | `sess-{uuid4().hex[:16]}`(`chat.py:223`) | 跨请求复用 |
|
||||
| `trace_id` | 单次请求 | `trc-{uuid4().hex[:16]}`(`trace.py:26`) | 每请求新建 |
|
||||
| `request_id` | 单次请求 | `req-{uuid4().hex[:16]}`(`trace.py:54`) | 每请求新建 |
|
||||
|
||||
**传播机制是 `contextvars`**(`trace.py:18`)——中间件 `set` 一次,之后任何层直接 `current_trace()` 读取,**不需要把 trace_id 当参数层层传递**。这是"全链路"的实现基础。
|
||||
|
||||
`trace_id` 与 `request_id` 分离是 B7 复审 P3-4 的整改结果:前者贯通链路,后者标识单次 HTTP 请求用于幂等对账,注释明确"两者不再互用"(`trace.py:19-20`)。
|
||||
|
||||
### 2.3 三条主链路
|
||||
|
||||
**(a) 同步对话** `POST /api/chat`
|
||||
|
||||
```
|
||||
trace 中间件(set trace/request)
|
||||
→ audit 中间件
|
||||
→ deps.get_auth_context(JWT 验签 + X-Agent-Type 准入)
|
||||
→ _guard_request 准入 → 空白 → 限流429 → 注入/超长400
|
||||
→ _prepare_turn 会话解析/创建 + 归属断言
|
||||
→ memory_service.get_recent(Redis 窗口,miss 回源 MySQL)
|
||||
→ agent_service.chat(LangGraph: tool → llm → guard)
|
||||
→ session_repository.insert_turn(user+assistant 同事务)
|
||||
→ memory_service.append_window
|
||||
→ audit 中间件落 http_access
|
||||
```
|
||||
|
||||
**(b) SSE 流式** `POST /api/chat/stream`
|
||||
|
||||
同步部分完全一致,差别在最后:`_events()` 生成器逐块 `yield`,**整轮收完 `done` 才一次性落库**(`chat.py:440` `insert_turn`)。
|
||||
|
||||
> **关键细节**:`StreamingResponse` 的生成器是在中间件 `finally` 已经 `reset_trace()` **之后**才被消费的,此时 `current_trace()` 已返回空串。代码在 `chat.py:397` 提前快照:
|
||||
> ```python
|
||||
> trace_id = current_trace() or "" # 在生成器外面,上下文还在时取值
|
||||
> ```
|
||||
> 生成器闭包捕获该变量,后续 `insert_turn(..., trace_id=trace_id)` 用快照而非现取。**若按常规写法在生成器里现取,SSE 落库的 trace_id 会全是空串**,消息历史直接断链。
|
||||
|
||||
**(c) 交易事件** `POST /api/simulate/trade`
|
||||
|
||||
```
|
||||
gateway.submit_trade
|
||||
→ suitability_check(R-02,唯一阻断点;阻断则 return,交易不落库)
|
||||
→ gateway_repository.insert_trade(写 core_trade)
|
||||
→ engine.process_trade_event(规则评估 → 预警落库 → L3 → AML)
|
||||
```
|
||||
|
||||
### 2.4 统一错误体契约
|
||||
|
||||
`utils/response.py:28` `error_body()` 固定输出:
|
||||
|
||||
```json
|
||||
{ "error_code": "...", "message": "...", "trace_id": "...", "request_id": "..." }
|
||||
```
|
||||
|
||||
- **4xx**:路由 `raise ApiError / PermissionDenied`(`utils/exceptions.py`),由 `register_error_handlers` 转 JSON。
|
||||
- **500**:由 `trace_middleware` 兜底(`main.py:102-106`)——异常发生在中间件以内时,Starlette 的 `ServerErrorMiddleware` 生成的 500 响应**不经过用户中间件**,会导致 trace 头丢失;所以这里 catch 后直接产出统一错误体。
|
||||
|
||||
---
|
||||
|
||||
## 3. 分层职责逐一讲解
|
||||
|
||||
### 3.1 `api/` — 路由与接缝
|
||||
|
||||
| 文件 | 职责 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| `deps.py` | 鉴权工厂 `get_auth_context`、准入矩阵、归属断言 | 已实现 |
|
||||
| `chat.py` | 对话四端点(同步 + 拉侧三端点 + SSE) | 已实现 |
|
||||
| `risk.py` / `simulate.py` | 风控 API / 模拟网关路由 | 已实现 |
|
||||
| `audit_middleware.py` | 每请求 `http_access` 审计 | 已实现 |
|
||||
| `auth_adapter.py` | 宿主→模块 `AuthContext` 适配 | **预制件,未接线** |
|
||||
| `admin.py` / `knowledge.py` | 仅一行 docstring | **空壳** |
|
||||
|
||||
**`admin.py` / `knowledge.py` 为什么是空的**:T-21 拍板"一期只做脚本入库,上传/重建端点不做",知识库入库走 `scripts/kb/build_kb.py` 离线脚本。二者未在 `main.py` 中 `include_router`,不暴露任何端点,也不影响启动。注意 `knowledge.py` 空 ≠ RAG 能力缺失——检索能力在 `service/rag_service.py` + `tool/kb_tools.py`,已可用。
|
||||
|
||||
**分层纪律**:api 允许调 service;**禁止直连 Milvus / 在路由里写复杂 SQL**。
|
||||
|
||||
### 3.2 `service/` — 编排与业务内核
|
||||
|
||||
分三类:
|
||||
|
||||
1. **对话编排**:`agent_service.py`(LangGraph 图)、`tool_service.py`(Tool 编排)、`memory_service.py`(记忆)
|
||||
2. **公共底座**:`auth_service.py`(JWT)、`input_guard.py`(输入防护)、`rag_service.py` / `milvus_service.py` / `embedding.py`(RAG)
|
||||
3. **风控业务**:`service/risk/*`(引擎、规则、预警、AML、L3、评分、锁、Redis 网关、对话 Tool)、`service/suitability.py`
|
||||
|
||||
### 3.3 `tool/` — 纯查询适配器
|
||||
|
||||
**边界定义**:Tool 只做"参数校验 → 调 service/repository → 归一化输出",**不承载业务流程**。
|
||||
|
||||
- `core_tools.py`:三个 Core 只读 Tool(`query_customer_profile` / `query_holdings` / `query_recent_trades`)
|
||||
- `kb_tools.py`:`search_knowledge`
|
||||
- `document_parser.py` / `embedding_tool.py` / `milvus_tool.py`:早期占位 stub(各不足 90 字节),T-21 的实际实现落在 `service/` 层
|
||||
|
||||
### 3.4 `repository/` — 数据访问
|
||||
|
||||
- `core_ro.py`:`CoreReadOnlyRepository`,**只 SELECT**
|
||||
- `session_repository.py`:`agent_session` / `agent_message` / `agent_tool_call`
|
||||
- `risk_repository.py`:风控四表 + 两张审计表(仅 INSERT)
|
||||
|
||||
### 3.5 `gateway/` — 模拟外部交易系统
|
||||
|
||||
**为什么单独成包**:它是全项目**唯一允许写 Core** 的模块(`FRAMEWORK.md` §3 明确列为分层例外)。生产环境由真实交易系统回调替代,**整个包可整体退役**。替换边界就是 `submit_trade(req)` 的契约。
|
||||
|
||||
### 3.6 `model/` — 占位层
|
||||
|
||||
`schemas.py` 仅 57 字节 docstring;`entities.py` 定义了 31 个 ORM 类但**运行时不使用**——docstring 明确"禁止 `create_all`、仅 IDE 参照、与 SQL 人工同步"。
|
||||
|
||||
**实际后果**:风控全程用 `dict` 传参(`insert_alert(alert: dict)` 等)。见 §6.6 的代价分析。
|
||||
|
||||
### 3.7 `config/` 与 `utils/`
|
||||
|
||||
- `config/settings.py`:`BaseSettings` + `.env`,进程单例 `settings = Settings()`
|
||||
- `utils/db.py`:`get_engine(database)` 按库名缓存单例(§4.6.2)
|
||||
- `utils/authz.py`:`record_authz_denial`,**下沉到 utils 的原因**是 service 层不得反向依赖 api
|
||||
- `utils/trace.py`、`utils/response.py`、`utils/exceptions.py`、`utils/desensitize.py`
|
||||
- `utils/logger.py`:**仅一行 docstring,完全占位**——全站日志无配置、无落盘、无结构化(见 §7.3)
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心子系统深讲
|
||||
|
||||
### 4.1 身份与准入体系
|
||||
|
||||
**职责与位置**:`api/deps.py` + `service/auth_service.py` + `utils/authz.py`,处于 api 层最前,是所有业务的前置闸门。
|
||||
|
||||
**核心实现**(`deps.py:225-279`):
|
||||
|
||||
```
|
||||
1. Authorization: Bearer 存在 → auth_service.verify_token(验签 + 必填 claims + jti 吊销)
|
||||
· Bearer 为全环境首选,大小写不敏感(auth_header[:7].lower() == "bearer ")
|
||||
2. X-Agent-Type 交叉校验 → 缺失 401 / 域外 400 / 不符矩阵 403(均落审计)
|
||||
3. 无 Bearer 时:仅当 app_env == "development" 且 jwt_public_key_path 为空
|
||||
→ 才走 X-Debug-Role / X-Debug-Actor 兜底(双闸门)
|
||||
```
|
||||
|
||||
**准入矩阵** `AGENT_ACCESS_MATRIX`(`deps.py:46-57`):
|
||||
|
||||
| Agent | token_type | roles |
|
||||
| --- | --- | --- |
|
||||
| customer | customer | customer |
|
||||
| advisor | staff | advisor / compliance / ops |
|
||||
| analyst | staff | analyst / compliance |
|
||||
| risk | staff, service | risk_officer / risk_manager / service_risk |
|
||||
|
||||
**`deny()` 的关键性质**(`deps.py:119-129`):**先落审计再抛异常**。审计下沉到 `utils/authz.record_authz_denial`,双写 `audit_log` + `input_guard_log`,然后 `raise PermissionDenied`。即 403 一律留痕,语义 fail-closed。
|
||||
|
||||
#### C5 红线:risk_manager 禁止走对话线(三重保险)
|
||||
|
||||
| 层 | 位置 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| ① 矩阵层 | `deps.py:53-56` | risk 行 roles **含** risk_manager → HTTP 台账放行 |
|
||||
| ② 对话入口 | `chat.py:122-124` | `if agent_type == "risk" and "risk_manager" in auth.roles: deny(...)` |
|
||||
| ③ Tool 层 | `tool_service.py:138-156` | `assert_tool_access` 只放行 risk_officer,其余 `AUTH_403_SCOPE` |
|
||||
|
||||
**为什么分三处而不是一处**:① 是能力声明(这个角色"能进哪个 Agent"),② 是业务线约束(同一 Agent 的两条线权限不同),③ 是纵深兜底(前两层被绕过时仍 fail-closed)。代价是**权限逻辑分散**,改动需三处同步(§7.4)。
|
||||
|
||||
#### 权衡
|
||||
|
||||
| 决策 | 选了 | 替代方案 | 代价/收益 |
|
||||
| --- | --- | --- | --- |
|
||||
| 身份来源 | JWT RS256(生产)+ HS256(dev) | 每次查库 session | JWT 免去 DB 往返,但吊销依赖 Redis jti 黑名单,Redis 挂则吊销失效(fail-open) |
|
||||
| dev 通道 | debug 头兜底 | 一律强制 JWT | 演示/CI 免签方便;但**双闸门一旦误配(生产设 `app_env=development` 且公钥为空)即放开无签名身份** |
|
||||
| 审计失败 | fail-open(仅 warning) | fail-closed | 可用性优先;合规强场景应切 fail-closed,已登记待决 |
|
||||
|
||||
### 4.2 对话编排子系统
|
||||
|
||||
#### 4.2.1 LangGraph 图结构
|
||||
|
||||
`agent_service.py:173-183`:
|
||||
|
||||
```python
|
||||
def build_graph():
|
||||
graph = StateGraph(ChatState)
|
||||
graph.add_node("tool", tool_node)
|
||||
graph.add_node("llm", llm_node)
|
||||
graph.add_node("guard", guard_node)
|
||||
graph.add_edge(START, "tool")
|
||||
graph.add_edge("tool", "llm")
|
||||
graph.add_edge("llm", "guard")
|
||||
graph.add_edge("guard", END)
|
||||
return graph.compile()
|
||||
```
|
||||
|
||||
**这是一个线性图,没有条件边**。四 Agent 的差异**不是靠图分支,而是靠数据驱动**:
|
||||
|
||||
- `_SYSTEM_PROMPTS`(`:40-56`)按 `agent_type` 固化四种角色边界
|
||||
- `_INTENT_KEYWORDS`(`tool_service.py:73-96`)按 agent 分组决定命中哪些 Tool
|
||||
- 结果:`analyst` 无关键词 → Tool 节点空转 → 纯 LLM;`risk` 命中风控 Tool;`customer`/`advisor` 命中 Core RO + KB
|
||||
|
||||
**`ChatState` 字段**(`:59-77`):`agent_type / history / user_message / messages(Annotated + operator.add) / reply / has_disclaimer / session_id / trace_id / actor / customer_id / tool_results`。`messages` 用 `operator.add` 归并以避免节点覆盖历史。
|
||||
|
||||
> **多解说明**:LangGraph 支持 `add_conditional_edges` 做真正的图分支。本项目**选择了数据驱动而非图分支**——理由是四 Agent 共享相同的"取数 → 生成 → 加免责声明"骨架,差异只在提示词与可用工具集,抽成一张图比维护四张子图成本低。代价是**无法表达"风控对话需要额外的复核节点"这类结构性差异**,若将来某 Agent 需要独立节点拓扑,需拆图。
|
||||
|
||||
**无 key 降级**(`:148-150`):`if not settings.deepseek_api_key: reply = _degraded_reply(state)`,降级回复携带 Tool 查询摘要并加 `_DEGRADED_PREFIX`,不抛异常。好处是演示链路不断;风险是**生产漏配 key 会"看起来正常"**(§7.3)。
|
||||
|
||||
#### 4.2.2 流式执行时序
|
||||
|
||||
`stream_chat`(`:275-319`):**Tool 同步跑完,再流式推 LLM 文本**(非交错)。
|
||||
|
||||
```
|
||||
tool_node(state) → tool_results
|
||||
_compose_messages(把 Tool 结果注入上下文)
|
||||
llm.stream(messages) → 逐块 yield ("delta", text)
|
||||
yield ("done", 完整正文)
|
||||
```
|
||||
|
||||
**为什么不交错**:Tool 结果要作为 LLM 输入且需落 `agent_tool_call` 留痕,必须同步完成;交错会让 LLM 在 Tool 未返回时"瞎编"——金融场景不可接受。代价是**首字延迟 = Tool 耗时 + LLM 首字**(§7.3)。
|
||||
|
||||
#### 4.2.3 Tool 编排
|
||||
|
||||
**注册表三层懒加载**(`tool_service.py:115-132`):
|
||||
|
||||
```
|
||||
core_tools.TOOL_REGISTRY → chat_tools.RISK_TOOL_REGISTRY → kb_tools.KB_TOOL_REGISTRY
|
||||
```
|
||||
|
||||
懒导入避免模块加载耦合。ToolSpec 字段:`func / description / requires_customer / param_whitelist / int_bounds [/ skip_access_check]`。
|
||||
|
||||
**意图识别是关键词匹配,不是 LLM**(`match_intent:100-112`):按 agent 分组遍历 `(tool_name, (kws...))`,`any(k in message for k in kws)`,**命中即停**(单意图)。
|
||||
|
||||
> **权衡**:LLM 路由更灵活但增加一次调用延迟与成本,且意图判定不可复现(不利于审计);关键词零延迟、可复现、单测可锁。代价是**用户换说法就漏触**("我的基金" vs "持仓"),且不支持多意图。
|
||||
|
||||
**归属校验**(`assert_tool_access:138-156`):
|
||||
|
||||
| 角色 | 规则 |
|
||||
| --- | --- |
|
||||
| risk_officer | 全量放行 |
|
||||
| customer | `actor_id == customer_id` |
|
||||
| advisor | `core_ro.is_advisor_assigned(actor_id, customer_id)` |
|
||||
| 其余 | `AUTH_403_SCOPE` fail-closed |
|
||||
|
||||
`skip_access_check=True` **仅 KB Tool**(公开知识无客户对象),开放范围由意图层约束(仅 customer/advisor 配词)。
|
||||
|
||||
**入参归一化**(`_normalize_params:162-196`):白名单 + 整数边界钳制,未知键直接报 `TOOL_BAD_PARAM` 而**不静默丢弃**——静默丢弃会让 LLM 以为参数生效了。
|
||||
|
||||
**留痕**:`run_tool` 执行后**无条件**调 `_audit_tool_call` 写 `agent_tool_call`(含 `tool_input / tool_output / status / latency_ms`),落库失败降级 warning 不阻塞。
|
||||
|
||||
#### 4.2.4 记忆分层
|
||||
|
||||
- **Redis 窗口**:key `sess:{agent}:{session_id}:msgs`,`WINDOW_SIZE=20`,`TTL=2h`,`RPUSH + LTRIM` 保最近 20 条
|
||||
- **MySQL 权威**:`insert_turn` 落 `agent_message`
|
||||
- **回源**:`get_recent` 先 `lrange`,**miss 或异常** fallback `list_messages`
|
||||
|
||||
**设计要点**:Redis 是**缓存不是权威**,丢了可重建。这与 Redis 在限流、发布订阅处的 fail-open 口径一致。
|
||||
|
||||
#### 4.2.5 输入防护(T-03)
|
||||
|
||||
顺序(`chat.py:144-206` `_guard_request`):
|
||||
|
||||
```
|
||||
Agent 准入 → 空白 → 限流 429 → 注入/超长 400 → 归属 → 会话
|
||||
```
|
||||
|
||||
- **注入词表 45 条**(`input_guard.py:43-94`;注:旧文档写 42 条,实测 45 条 = 指令覆盖 18 + 角色重置 11 + 系统提示泄露 9 + 越权诱导 7)。短语**精确子串匹配**,不做模糊正则(防误杀"忽略这只股票"这类业务句)。
|
||||
- **oversize**:`MESSAGE_MAX_LENGTH = 4000`
|
||||
- **限流**:Redis **固定窗口**,actor 级 `ratelimit:{agent}:{actor}`,默认 30/min,**fail-open**
|
||||
|
||||
> **为什么限流 fail-open 而鉴权 fail-closed**:限流是可用性保护,不是安全边界;安全边界(鉴权/归属/注入)必须 fail-closed。这个区分是刻意的。代价是 Redis 故障时恶意用户可绕过限流(§7.3)。
|
||||
|
||||
> **为什么限流排在内容防护之前**:计数需覆盖全部请求(含将被注入拦截的),让重复攻击者快速收敛到 429。
|
||||
|
||||
### 4.3 风控事件线
|
||||
|
||||
#### 4.3.1 完整调用链
|
||||
|
||||
```
|
||||
app/gateway/trade_gateway.py:86 submit_trade
|
||||
├─ :122 suitability_check ← R-02 唯一阻断点
|
||||
│ └─ blocked → record_suitability_alert → return(交易不落库)
|
||||
├─ :167 gateway_repository.insert_trade(写 core_trade)
|
||||
└─ :171 engine.process_trade_event(同步)
|
||||
engine.py:114
|
||||
├─ core.list_trades_range(取当日流水)
|
||||
├─ :139 run_rules → RISK-001~005 评估
|
||||
├─ :145 rule_concentration(RISK-006,输入域为持仓,故不并入 run_rules)
|
||||
├─ :150 record_trade_alerts(聚合落 risk_alert)
|
||||
├─ :160 upsert_profile_l3(L3 监测层)
|
||||
├─ :174 match_customer(AML)
|
||||
└─ :177 record_aml_alert
|
||||
```
|
||||
|
||||
#### 4.3.2 规则与评分
|
||||
|
||||
`service/risk/rules.py` 全部为**纯函数**,`run_rules:201` 编排:
|
||||
|
||||
| 规则 | 函数 | 阈值来源 |
|
||||
| --- | --- | --- |
|
||||
| RISK-001 大额 | `rule_large_amount:91` | `risk_large_amount = 500000` |
|
||||
| RISK-002 当日累计 | `rule_daily_total:102` | `risk_daily_total = 500000` |
|
||||
| RISK-003 频繁交易 | `rule_freq_trade:113` | `risk_freq_count = 3` |
|
||||
| RISK-004 试探性 | `rule_probe_pattern:127` | 5 分钟内 3 笔 × 40 万 |
|
||||
| RISK-005 小额铺垫 | `rule_small_then_large:145` | 1 万 × 3 笔 |
|
||||
| RISK-006 集中度(C4) | `rule_concentration:164` | `concentration_threshold = 0.80` |
|
||||
|
||||
**评分是静态映射**(`RULE_SCORES:18`):001/002 = 70、003 = 50、004/005 = 80、006 = 60;`alert_service.py:22 TIER_SCORE` sustainability = 90、aml = 95。
|
||||
|
||||
> ⚠️ **`service/risk/scoring.py` 不是实际评分器**——`recompute_customer_score` 直接 `raise NotImplementedError("R-05 dynamic scoring not implemented")`,是签名冻结的预留桩。L3 的 `risk_score` 一期恒为 NULL。
|
||||
|
||||
#### 4.3.3 为什么只有 R-02 能阻断
|
||||
|
||||
**代码级物理隔离**,两套路径不共用出口:
|
||||
|
||||
- R-02 在**网关层**(`submit_trade:126`):`if result.blocked: ...; return {blocked: True}`,此时 `insert_trade`(`:167`)**尚未执行**
|
||||
- RISK-001~006 全在 `process_trade_event` 内,命中只调 `record_trade_alerts` 出 `risk_alert(status=pending_review)`;`engine.py:122` 注释明确事件线全程 `return {"blocked": False, ...}`,**引擎无任何阻断出口**
|
||||
|
||||
判定权唯一归属 `core_ro.check_suitability` 返回的 `blocked` 字段。
|
||||
|
||||
#### 4.3.4 L3 监测层
|
||||
|
||||
`customer_profile_l3` 表,字段 `monitor_tier`(normal < watch < high)、`monitor_tags`(追加合并)、`risk_score`(一期 NULL)、`last_alert_id`、`computed_at`。
|
||||
|
||||
**并发控制**:进程内锁 + 乐观锁 `update_l3(expected_computed_at)` + 3 次重试。
|
||||
|
||||
> ⚠️ **`service/risk/locks.py` 不是分布式锁**:`run_locked:31` 用的是**进程内 `threading.Lock`**,按 key 串行,`LOCK_TIMEOUT_SECONDS = 2.0` 超时降级 `fn(locked=False)`。文件注释明确"多进程部署换 Redis SET NX,接口不变"——**这是 TODO,当前无分布式实现**。单进程演示成立,多实例部署会失效(§7.2)。
|
||||
|
||||
#### 4.3.5 C4 / C5 / C6 追加需求
|
||||
|
||||
| 需求 | 实现 | 核心逻辑 |
|
||||
| --- | --- | --- |
|
||||
| C4 集中度 | `rules.py:164` + `engine.py:144` | R4+R5 市值占比 ≥ 0.80;`holdings_truncated` 视同达标(保守口径) |
|
||||
| C5 时效升级 | `escalation_service.py:59/84` | 普通 4h/24h、AML 1h/4h;**只写 `payload.escalation_level`,不改 status**;幂等仅升不降 |
|
||||
| C6 行为链 | `agent_behavior_service.py:108/289` | A 诱导调仓(赎回 2h 内申购不同产品,3 次)/ B 越权试探(5 次/72h)/ C 越权查询(10 次/24h);出**代理人维度**独立单 |
|
||||
|
||||
C6 的单据被 `find_pending_event_alert` 显式排除,避免误并入客户单。
|
||||
|
||||
#### 4.3.6 引擎异常补偿
|
||||
|
||||
`trade_gateway.py:185-203`:交易已提交但引擎异常 → 记 `risk_engine_error` 审计 + `engine_error=true`,由 `scripts/demo/rebuild_alerts.py` 补偿重放。
|
||||
|
||||
> **这是最终一致而非原子**:写 `core_trade`(core 库)与写 `risk_alert`(agent 库)是两个独立事务,双库无法纳入同一事务(§6.2)。
|
||||
|
||||
### 4.4 风控对话线
|
||||
|
||||
`service/risk/chat_tools.py:328` `RISK_TOOL_REGISTRY` 实际注册 **5 个**:
|
||||
|
||||
| Tool | 说明 | requires_customer |
|
||||
| --- | --- | --- |
|
||||
| `query_overdue_alerts` | 超期未处置预警(C5) | False |
|
||||
| `alert_query` | 预警查询(risk_officer 可查全量) | False |
|
||||
| `customer_context` | 客户风控上下文 | — |
|
||||
| `suitability_check` | 适当性校验(只落 `risk_suitability_log`) | — |
|
||||
| `aml_lookup` | AML 名单查询 | — |
|
||||
|
||||
(`query_agent_behavior:276` 已定义但未注册,仅由意图层使用。旧文档"四个 Tool"为过时口径。)
|
||||
|
||||
**只读边界**:全部无 insert alert / 处置 / 改表动作。事件线独占出预警单、人工处置、AML 扫描。`suitability_check` 复用 `service/suitability.py`,只落审计日志。
|
||||
|
||||
### 4.5 RAG 链路
|
||||
|
||||
| 环节 | 实现 |
|
||||
| --- | --- |
|
||||
| Embedding | `service/embedding.py`,Ollama bge-m3,1024 维 |
|
||||
| 向量库 | `service/milvus_service.py`,collection `kb_product_rules`,schema 含溯源字段(`source_doc_id` / `source_version` / `effective_date`) |
|
||||
| 检索 | `search_kb:119-166`,filter 内建 `effective_date <= today`(只返回已生效文档) |
|
||||
| 输出 | `rag_service.search_knowledge:53-77` 返回 `chunks + source_refs`(去重溯源清单) |
|
||||
|
||||
**Ollama 失败 = 报错,不降级**(`embedding.py:7-10`):禁止零向量或截断,一律抛 `EmbeddingError`,由 `run_tool` 转 `TOOL_ERROR`。
|
||||
|
||||
> **为什么这里反而 fail-closed**:假装"没有结果"会让 LLM 编造产品规则——在金融合规场景下,暴露"查询未完成"比给出错误答案安全得多。与限流的 fail-open 形成对比,说明**降级策略是按"失败后果"逐项决定的,不是全局统一口径**。
|
||||
|
||||
### 4.6 数据访问与审计
|
||||
|
||||
#### 4.6.1 `core_ro` 只读契约
|
||||
|
||||
`CoreReadOnlyRepository`(`core_ro.py:51-498`),绑定 `get_engine(settings.mysql_core_database)`,全部方法用 `self._engine.connect()` 执行 `text()` SELECT。
|
||||
|
||||
> ⚠️ **只读靠约定,不靠强制**:类注释声明"仅 SELECT",但**没有 DB 级只读账号,没有 SQL 层拦截**。任何持有 core engine 的代码都能 INSERT。改进见 §7.1。
|
||||
|
||||
**`check_suitability`(`:80-199`)—— 全系统唯一的阻断判定核**:
|
||||
|
||||
SQL 取回 `core_suitability_rule` 的 `matrix_match_result`(`LEFT JOIN ... ON sr.customer_risk_code = r.risk_code AND sr.product_risk_code = p.min_risk_code`),然后按序判定:
|
||||
|
||||
```
|
||||
1. 客户/产品缺失 → forbidden / SUIT_NOT_FOUND
|
||||
2. risk_is_expired (FM-03) → 阻断 / SUIT_RISK_EXPIRED
|
||||
3. professional 投资者 → 豁免 / professional_exempt (JR-AST-PRO)
|
||||
4. matrix == forbidden/null → 阻断 / SUIT_RISK_MISMATCH (JR-AST-012)
|
||||
5. 否则 → allowed / allowed_with_disclosure
|
||||
6. age >= 70 且产品 >= R3 → 阻断 / SUIT_AGE_CONFIRM (FM-01)
|
||||
```
|
||||
|
||||
结果经 `_suitability_result`(`:201-246`,21 字段)与 `risk_suitability_log` 一一对应。
|
||||
|
||||
**矩阵判定是数据驱动的**(查表而非硬编码 if-else),新增风险等级组合只需改 `core_suitability_rule` 表数据。这是阶段一 AL-01~08 的整改成果(旧的 `SUIT-001~008` 纯函数矩阵已退役)。
|
||||
|
||||
#### 4.6.2 双库实现与代价
|
||||
|
||||
`utils/db.py:23-36` `get_engine(database)` 按库名缓存 `Engine` 单例(字典 + `threading.Lock`)。两个库各自持有独立 engine。
|
||||
|
||||
**代价:跨库无法 JOIN**。代码从不跨库联表,而是在 Python 层分别取数合并(如 `submit_trade` 同时调 `core_ro` 与 `risk_repo`)。更进一步,**两库的写操作无法纳入同一事务**——这是 §4.3.6 最终一致性的根因。
|
||||
|
||||
#### 4.6.3 会话三表与 `insert_turn`
|
||||
|
||||
- `agent_session`:`session_id`(UK) / `trace_id` / `agent_type` / `actor_id` / `customer_id` / `status`(active|closed|blocked) / `metadata`
|
||||
- `agent_message`:`session_id` / `trace_id` / `seq_no` / `role` / `content` / `has_disclaimer`
|
||||
- `agent_tool_call`:`session_id` / `trace_id` / `tool_name` / `tool_input` / `tool_output` / `status` / `latency_ms`
|
||||
|
||||
**`insert_turn`(`:218-275`)同事务落库**:`with self._engine.begin()` 内先取 `MAX(seq_no)+1`,再连续 INSERT user / assistant。杜绝"user 落了 assistant 没落"的半截历史污染 LLM 上下文。
|
||||
|
||||
`close_session`(`:78`)用条件 `UPDATE ... WHERE status='active'`,幂等返回 `rowcount > 0`。
|
||||
|
||||
> ⚠️ **`agent_message` 的 `(session_id, seq_no)` 是普通索引(`KEY idx_session_seq`)而非唯一索引**,且 `insert_turn` 取 seq 的 SELECT 无 `FOR UPDATE`,并发同会话会**静默产生重号消息**。见 §7.3。
|
||||
|
||||
#### 4.6.4 审计表
|
||||
|
||||
- `audit_log`:`trace_id` / `event_type` / `agent_type` / `actor_id` / `customer_id` / `rule_id` / `input_summary`(JSON) / `decision` / `risk_score` / `handler_*` / `created_at` —— **只 INSERT**
|
||||
- `input_guard_log`:`trace_id` / `session_id` / `agent_type` / `actor_id` / `guard_type`(ENUM 四值) / `raw_excerpt` / `action`(blocked|sanitized|passed)
|
||||
|
||||
**"双写"在项目里有两处含义**,注意区分:
|
||||
|
||||
1. **越权双写**:`deny` 同时写 `audit_log` + `input_guard_log`(`utils/authz.py:44-76`,**无事务**)
|
||||
2. **事件双写**:每笔交易同时落 `risk_alert` + `audit_log`;处置时 `handle_alert_with_audit:309` 状态变更与审计**同事务**(防无痕状态变更)
|
||||
|
||||
---
|
||||
|
||||
## 5. 模块协作:依赖方向、接口边界与扩展点
|
||||
|
||||
### 5.1 依赖方向规则
|
||||
|
||||
```
|
||||
允许: api → service → tool / repository / model / config
|
||||
禁止: api 直连 Milvus;api 写复杂 SQL;tool 写业务流程;repository 写 Core
|
||||
例外: app/gateway/ 为模拟外部系统模块,其 gateway_repository 可 INSERT core_trade
|
||||
反向依赖禁止:service 不得依赖 api(这就是 utils/authz.py 存在的原因)
|
||||
```
|
||||
|
||||
### 5.2 接口边界速查
|
||||
|
||||
| 边界 | 契约 | 扩展方式 |
|
||||
| --- | --- | --- |
|
||||
| 宿主 → 模块鉴权 | `auth_adapter.from_host_auth`(**未接线**) | AL-09 合并后接入 |
|
||||
| api → service | Pydantic 请求模型 + `AuthContext` | 新增端点只需加路由 |
|
||||
| 对话 → Tool | `get_registered_tool` 注册表白名单 | **新增 Tool = 注册表加一项 + 意图词表加词** |
|
||||
| 事件 → 规则 | `rules.py` 纯函数 + `RiskThresholds.from_settings` | 阈值改 `.env`,新规则改代码 |
|
||||
| 模块 → Core | `core_ro` 纯 SELECT | 只读,不动 |
|
||||
| 阻断判定 | `core_suitability_rule` 表数据 | **改数据即可,不改代码** |
|
||||
|
||||
### 5.3 主要扩展点
|
||||
|
||||
1. **新增对话 Tool**:注册表加一项(core / risk / kb 三选一)+ `_INTENT_KEYWORDS` 配词
|
||||
2. **新增风控规则**:`rules.py` 加纯函数 + `run_rules` 编排 + `RULE_SCORES` 映射
|
||||
3. **接入动态评分**:实现 `scoring.py:recompute_customer_score`(签名已冻结),同步放开 L3 `risk_score`
|
||||
4. **替换真实交易系统**:整体退役 `app/gateway/`,实现 `submit_trade` 契约
|
||||
5. **切分布式锁**:`locks.py` 换 Redis SET NX(**接口不变**,注释已预留)
|
||||
6. **前端接入**:后端接入面已就绪(方案 B 三端点 + 方案 C SSE),`web/` 待 init
|
||||
|
||||
---
|
||||
|
||||
## 6. 设计权衡总表
|
||||
|
||||
| # | 设计决策 | 采用方案 | 替代方案 | 采用方案的收益 | 替代方案的代价 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 6.1 | 四 Agent 差异表达 | 一张图 + 数据驱动(提示词+意图表) | 四张子图 + 条件边 | 维护成本低、骨架统一 | 无法表达结构性差异(如风控复核节点) |
|
||||
| 6.2 | 双库 | `jinrong_core` + `jinrong_agent` 物理隔离 | 单库 + schema 前缀 | Core 红线靠物理隔离兜底 | 跨库不能 JOIN、无分布式事务 → 最终一致 |
|
||||
| 6.3 | 意图识别 | 关键词匹配 | LLM function calling | 零延迟、可复现、可单测 | 换说法漏触、不支持多意图 |
|
||||
| 6.4 | 流式与 Tool | Tool 同步跑完再流式推 | Tool 与 LLM 交错 | 结果确定性、可留痕 | 首字延迟 = Tool 耗时 + LLM 首字 |
|
||||
| 6.5 | 只读保障 | 约定 + 分库 | DB 只读账号 + ORM 强制 | 实现简单 | 无强制,任何持有 engine 的代码都能写 |
|
||||
| 6.6 | 数据传参 | dict | Pydantic / TypedDict / ORM | 敏捷,快速迭代 | 无编译期校验,拼写错误运行时才暴露;`entities.py` 31 个类闲置 |
|
||||
| 6.7 | 限流降级 | fail-open | fail-closed | Redis 故障不阻断业务 | 故障时恶意用户可绕过 |
|
||||
| 6.8 | Embedding 降级 | fail-closed(抛错) | 返回空结果 | 避免 LLM 编造产品规则 | 依赖可用性,Ollama/Milvus 故障直接报错 |
|
||||
| 6.9 | 会话落库 | 整轮一次性落(同事务) | 边生成边落 | 无半截历史 | 断连/异常整轮丢失(Tool 留痕仍在) |
|
||||
| 6.10 | 锁 | 进程内 `threading.Lock` | Redis SET NX | 零依赖、单进程够用 | 多实例部署失效(TODO 未做) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 前提假设、适用边界与改进空间
|
||||
|
||||
### 7.1 前提假设(不成立则设计失效)
|
||||
|
||||
1. **单进程部署**——`locks.py` 进程内锁、`insert_turn` 的 `MAX(seq_no)+1` 竞态(注释承认"并发写锁归后续")均以此为前提
|
||||
2. **Core 是模拟库且无人写入**——只读靠约定,无 DB 级账号保护
|
||||
3. **用户用语高度收敛**——关键词意图识别的前提
|
||||
4. **演示/内网环境**——dev debug 通道、`_degraded_reply` 静默降级均在公网生产下变危险
|
||||
5. **SQLite 测试与 MySQL 生产行为一致**——项目大量 Python 端垫片(`_as_date` / `_is_expired` / `str(amount)` / `_jsonable`)源于此
|
||||
|
||||
### 7.2 适用边界
|
||||
|
||||
| 能力 | 边界 |
|
||||
| --- | --- |
|
||||
| 分布式锁 | ❌ 多实例部署失效 |
|
||||
| 动态风险评分 | ❌ `scoring.py` 是桩,L3 `risk_score` 恒 NULL |
|
||||
| 跨库事务 | ❌ 双库最终一致,靠 `rebuild_alerts.py` 补偿 |
|
||||
| 知识库管理 API | ❌ 一期只做脚本入库 |
|
||||
| 图数据库 | ⚠️ Neo4j 仅由 `sync_neo4j.py` 灌图,运行时未参与主链路 |
|
||||
| Milvus Lite | ⚠️ 演示规模,不适合生产 HA |
|
||||
| `convert` 交易 | ❌ 网关主动拒绝(DB ENUM 支持,代码收窄) |
|
||||
|
||||
### 7.3 主要改进空间(按优先级)
|
||||
|
||||
**P0 — 安全与正确**
|
||||
|
||||
1. **为 `core_ro` 配 DB 级只读账号**:当前红线仅靠约定,配只读账号是从根基卡死(§4.6.1)
|
||||
2. **`agent_message` 并发重号**:索引非唯一 + SELECT 无锁 → 并发同会话静默重号。修法:加 `UNIQUE(session_id, seq_no)` **并同步给 `insert_turn` 加 `IntegrityError` 重试**(只加索引会让并发变 500)
|
||||
3. **无 key 降级改显式告警**:`_degraded_reply` 静默返回,生产漏配会"看似正常"
|
||||
4. **审计/限流 fail-open 策略可配置**:合规强场景应能切 fail-closed
|
||||
|
||||
**P1 — 可维护性**
|
||||
|
||||
5. **中间件顺序加测试守卫**:当前靠注释固化,调换装饰器会静默丢 trace_id(§2.1)
|
||||
6. **权限逻辑收拢**:C5 红线分散在矩阵 / `_assert_chat_entry` / `assert_tool_access` 三处
|
||||
7. **注册表合并**:`TOOL_REGISTRY` 与 `KB_TOOL_REGISTRY` 分裂
|
||||
8. **`model/` 层启用**:逐步用 Pydantic / `TypedDict` 约束仓储入参
|
||||
|
||||
**P2 — 体验与能力**
|
||||
|
||||
9. **意图识别升级**:关键词 → 同义词扩展 + 多意图(谨慎用 LLM,会损失可复现性)
|
||||
10. **流式首字优化**:Tool 执行期间先推"查询中"占位帧
|
||||
11. **注入词表运营化**:45 条硬编码,宜配置化 + 持续红队补充
|
||||
12. **限流改滑动窗口**:当前 INCR + 首命中 EXPIRE 非原子
|
||||
13. **日志基建**:`utils/logger.py` 仅一行 docstring,需 basicConfig + 落盘 + trace_id 注入
|
||||
|
||||
### 7.4 已知待决事项
|
||||
|
||||
- 审计降级 fail-open 是否切 fail-closed(已登记)
|
||||
- 进程内锁换 Redis SET NX 的时机(接口已预留)
|
||||
- `chat` 链路 `risk_suitability_log.actor_id` 落 `SYSTEM` 待评估
|
||||
- 前端 React 多 Agent 入口 `web/` 归属待拍板
|
||||
|
||||
---
|
||||
|
||||
## 8. 多解处:代码实际采用哪一种
|
||||
|
||||
| 议题 | 候选 | 实际采用 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 四 Agent 是否各用一张 LangGraph 图 | A 四张子图 / B 一张图数据驱动 | **B** | 骨架相同,差异仅在提示词与工具集 |
|
||||
| Tool 与 LLM 是否交错流式 | A 交错 / B Tool 先同步跑完 | **B** | 金融场景不接受 LLM 在 Tool 未返回时编造 |
|
||||
| 意图识别用 LLM 还是规则 | A LLM function calling / B 关键词 | **B** | 零延迟、可复现、可单测锁定 |
|
||||
| 适当性矩阵硬编码还是查表 | A 硬编码 if-else / B 查 `core_suitability_rule` | **B** | 阶段一 AL-01~08 整改成果,新增组合只改数据 |
|
||||
| 失败降级统一 fail-open 还是 fail-closed | A 统一 / B 按失败后果逐项定 | **B** | 限流 fail-open(可用性保护),embedding fail-closed(防编造) |
|
||||
| 阻断逻辑放引擎还是网关 | A 引擎统一判定 / B 网关专用分支 | **B** | 只有 R-02 能阻断,物理隔离防误扩权 |
|
||||
| 数据传参用 ORM 还是 dict | A ORM / TypedDict / B dict | **B** | 快速迭代优先,代价是失去编译期校验 |
|
||||
| 新增/变更双写是否包事务 | A 统一包 / B 按后果定 | **B** | 新增可丢留痕;变更(处置)必须同事务,否则无痕状态变更 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 附录
|
||||
|
||||
### 9.1 关键文件索引
|
||||
|
||||
| 关注点 | 文件 |
|
||||
| --- | --- |
|
||||
| 装配与中间件栈 | `app/main.py` |
|
||||
| 鉴权与准入 | `app/api/deps.py`、`app/service/auth_service.py`、`app/utils/authz.py` |
|
||||
| 对话四端点 | `app/api/chat.py` |
|
||||
| LangGraph 编排 | `app/service/agent_service.py` |
|
||||
| Tool 编排 | `app/service/tool_service.py`、`app/tool/core_tools.py`、`app/service/risk/chat_tools.py`、`app/tool/kb_tools.py` |
|
||||
| 输入防护 | `app/service/input_guard.py` |
|
||||
| 风控事件线 | `app/gateway/trade_gateway.py`、`app/service/risk/engine.py`、`app/service/risk/rules.py` |
|
||||
| 适当性判定 | `app/repository/core_ro.py::check_suitability`、`app/service/suitability.py` |
|
||||
| 数据访问 | `app/repository/core_ro.py`、`session_repository.py`、`risk_repository.py` |
|
||||
| 双 ID 贯通 | `app/utils/trace.py` |
|
||||
| 并发原语 | `app/service/risk/locks.py`、`app/service/risk/redis_gateway.py` |
|
||||
|
||||
### 9.2 风控阈值清单(`config/settings.py:48-60`)
|
||||
|
||||
`risk_large_amount=500000`、`risk_daily_total=500000`、`risk_freq_count=3`、`risk_probe_window_minutes=5` / `risk_probe_count=3` / `risk_probe_amount=400000`、`risk_small_amount=10000` / `risk_small_count=3`、`risk_aml_default_threshold=0.85`、`concentration_threshold=0.80`、`guard_rate_limit_max=30/60s`
|
||||
|
||||
### 9.3 表清单
|
||||
|
||||
- **共用 11 张**:`agent_session` / `agent_message` / `agent_tool_call` / `audit_log` / `input_guard_log` / `customer_advisor_rel` / `customer_profile_l1` / `customer_profile_l2` / `customer_profile_l3` / `risk_alert` / `risk_suitability_log`
|
||||
- **Agent 专用 6 张**:`customer_threshold_config` / `customer_notify_log` / `advisor_draft` / `compliance_hit_log` / `analytics_query_log` / `risk_aml_list`
|
||||
- **Core 12 张**:`scripts/core/01-ddl.sql`
|
||||
|
||||
### 9.4 文档口径与代码实测不一致处(以代码为准)
|
||||
|
||||
| 项 | 旧文档口径 | 代码实测 |
|
||||
| --- | --- | --- |
|
||||
| 注入词表条数 | 42 条 | **45 条**(`input_guard.py:43-94`) |
|
||||
| 风控对话 Tool | 4 个 | **5 个**(`chat_tools.py:328`,另有 1 个定义未注册) |
|
||||
| Agent 专用表 | 5 张 | **6 张**(SQL 注释写 5,实建 6) |
|
||||
| `scoring.py` | 未明确 | **`NotImplementedError` 预留桩**,非实际评分器 |
|
||||
| `locks.py` | 未明确 | **进程内 `threading.Lock`**,非分布式锁 |
|
||||
| `convert` 交易 | 未提及 | DB ENUM 支持,**网关主动拒绝** |
|
||||
|
||||
---
|
||||
|
||||
*本文基于 `risk-control-agent` 分支 `2d0e2fa` 的代码实测撰写。后续改动请先更新 `docs/memory/` 再同步本文。*
|
||||
Reference in New Issue
Block a user