# xlyAi Agent 目标架构 > 状态:设计已收敛(2026-07-21)。全 Java / LangChain4j,单 ReAct agent + 通用工具 + 人在环写入。 > 关联:知识图谱底座见 `sql/README.md`;现状代码接线见 memory `xlyai-rag-milvus-wiring`。 > > **阅读指引**:§1–§14 是**设计**(决策与取舍,基本稳定);§15–§20 是**历史实现快照**(按日期,不再更新); > **当前实现状态看 §21(rearch3:写路径收权 + MySQL 记忆底座 + 技能入库)**。分支 `agent-main`。 ## 1. 背景与目标 把 xlyAi 从「元数据驱动的多智能体(8 场景 + 每表单一个 action 工具)」重构成: **单个 ReAct agent + 少量通用工具 + 人在环(HITL)写入**。agent 自己判断调工具,不再按场景路由。 **关键决策(含取舍)** - **全 Java / LangChain4j,不引入 langgraph4j、不换 Python/JS。** 代价:没有 LangGraph 的 checkpoint/interrupt/subgraph,HITL 与多步编排要手搓(用 immediate-return + ChatMemory + Redis,够应付请求-响应式对话 + 单步确认;复杂长事务将来会更费劲)。 - **观测**用自托管 **Langfuse**(不用 LangSmith,避免 ERP 数据出网)。 - **写入永远人在环双确认**,LLM 绝不直写业务表。 ## 2. 拓扑与数据流 ``` 浏览器前端 xlyAi (Java/LangChain4j :8099) saas ERP 后端 xlyEntry(:8697) ├ 聊天 UI(流式/diff确认/QA选项) ⇄ ┌ 意图门(受约束JSON) → 按意图收窄工具集 ⇄ ├ 表单读写 API (getBusinessData…) └ ERP 原生表单(预填/执行确认) ├ 单 ReAct Agent (AiServices 工具循环) ├ 暂存执行器 (/ai/execStaging) │ ChatMemory(Redis) · SystemPrompt(L1) └ MySQL (业务库) │ 9 个工具(6 类) + Skill 注册表 └ 直连: Ollama(qwen3:14b)/MySQL(KG+NL2SQL)(Milvus 已随 2026-07-27 精简移除,向量召回未建) ``` **数据流要点(重要)** - **Read / Write 的业务逻辑在 ERP 后端**,不在 xlyAi。xlyAi 的工具 = **薄 HTTP 客户端**,发和前端一样的 API 请求(`erp.baseurl` + `getBusinessDataByFormcustomId`)。 - **Query / NL2SQL 例外:xlyAi 直连 MySQL**(`DynamicExeDbService`),不经 ERP 后端 → 这是它击穿权限的根源,需单独锁(见 §7、§9)。 ## 3. Agent 核心 - **单 agent** = LangChain4j `AiServices` 原生 **tool-calling 循环**当 ReAct(不用老式文本 Thought/Action;qwen3:14b 支持 function calling)。 - **退役** 8 场景 `SceneSelector` 路由 —— agent 自路由。 > 实现修正(2026-07-23,见 §17):纯自路由在 qwen3:14b 上不可靠 → 前置一道**确定性意图门 + 按意图收窄工具集**。 > 这不是回退到场景路由(不再有人设/每表单工具),而是把「判类」从模型的 ReAct 里拿出来单独做。 - **System prompt** = 角色 + **L1 业务域图(`viw_kg_domain`, 11 行)** + Skill 摘要列表 + 工具用法 + 安全约束。 - **记忆** = `ChatMemory`(窗口化 + 超长摘要),持久化到 **Redis**(键:`chat:{userId}:{conversationId}`,带 TTL)。**多条命名会话/用户**:每用户一个会话列表,存储按 `conversationId` 分;按稳定身份(userId+品牌+子公司)寻址,**重登录/换 token 后接回历史**(token 不是会话 key)。 - **循环护栏**:最大工具迭代步数、单轮墙钟超时、单轮 LLM 调用上限。 ## 4. HITL 机制(无 LangGraph 的中断/续跑) 用 LangChain4j 的 **immediate-return tool**(现有 `immediateReturnToolNames` 机制):这类工具一被调用就把结果**直接返回前端并结束本轮**,不回喂 LLM。 - **AskUser / ProposeWrite / 表单呈现** 都是 immediate-return。 - **续跑**:本轮结束后,会话置 `pendingInteraction`(如 `awaiting_answer:{id}` / `awaiting_confirm:{stagingRef}`)。用户的下一条输入按此状态被路由回「上一步的答复」,而非当作新请求。**没有这个 pending 状态机,多轮 HITL 会错乱** —— 这是手搓 HITL 的关键点。 - **确认动作走确定性端点,不过 LLM**:diff 的「确认」按钮 → 直接打 xlyAi 一个 endpoint 落暂存 + 返回 ERP 链接。 > ⚠️ **本节仍是设计,未按此实现**(截至 2026-07-23)。实际做法:新 agent **没有**用 > `immediateReturnToolNames`(只有休眠的旧 `XlyErpService` 用),工具结果仍回喂 LLM;也**没有** > `pendingInteraction` 状态机——多轮靠 `ChatMemory` 自然接续 + 表单提交时前端在消息里塞 > `proposeWrite(action=create)` 标记让控制器识别。卡片/控件由控制器在 `onToolExecuted` 里推 SSE。 > 已知代价见 §18(LLM 看得见提议 JSON,可能谎称"已完成",目前只靠 prompt 规则压制)。 **前端事件协议(SSE)**:`token`(流式文本)/ `tool_call`(可选可视化)/ `question{text,options[]}` / `write_proposal{diff,stagingRef}` / `link{url}` / `error` / `done`。 ## 5. 工具规格(6 类通用工具) > 通用原则:入参/出参 JSON-schema、无状态、**auth 走 per-call context**(MCP 形状,便于日后暴露成 MCP)。输出**必须分页/截断**,绝不吐爆上下文。 > > 下表是**能力分类**(6 类);实现上落成 9 个 `@Tool` 方法(Read 拆 `readFormData`/`lookupRecord`, > KgSearch 拆 `findForms`/`kgSearch`,其余各 1 个)。清单见 §17。 | # | 工具 | 职责 | 关键入参 | 出参 | 安全 | HITL | |---|------|------|----------|------|------|------| | 1 | **Read** | 调**任意 ERP 表单/过程**(明细 **+ 预建聚合** AR/AP/库存/进度,即旧 type-5),包 `getBusinessDataByFormcustomId` | formId, moduleId, **白名单过滤**, page | 行(截断)+分页游标 | 租户+行级;**表单权限靠 xlyAi 授权层**(§7);参数白名单 | 否 | | 2 | **Query** | 只读 SQL 沙箱,**仅 ad-hoc 兜底**(没有现成表单/过程能答时才用) | 自然语言 → SQL | 聚合结果(小) | ⚠ 见 §9 | 否 | | 3 | **KgSearch** | L2 邻居 / L3 字段→列→表 / 解析 formId | 表单名/术语/域 | 子图/映射 | 只读 | 否 | | 4 | **AskUser** | 小问题(选项 + 自由输入),消歧/澄清 | question, options[] | — (immediate-return) | — | ✅ | | 5 | **FormCollect(表单呈现)** | 渲染一张表单收集 N 个参数(字段/类型/下拉来自 ERP 元数据),用户填 | formId(+ 已知预填) | 收集到的参数 | — | ✅ | | 6 | **ProposeWrite** | 出 diff → 确认 → 暂存(单一写工具) | formId, op, payload | diff + stagingRef | 见 §12 | ✅ | - **路由原则**:预建表单/过程能答的 → **Read(安全)**;纯 ad-hoc 分析 → **Query(锁死)**。压缩 Query 危险面。 - **Read/ProposeWrite 的 formId 定位**依赖 KgSearch(过渡期先用表单名模糊匹配 `viw_ai_useful_forms`)。 - **「取表单 schema」能力**(字段/类型/必填/下拉/默认,数据在 `gdsconfigformslave`)供 **FormCollect 渲染** 和 ProposeWrite 预填。复杂写(报价 25 字段)= FormCollect 渲染该表单让用户填,**无需 Skill**。 ## 6. Skills(自搭轻量版,框架无原生支持) - Skill = 针对重复任务的 **playbook**(如 月度对账 / 新建报价 / 超期应收催收),**用工具但不是工具**。 - 注册表:`{name, 何时用(1行), 详细指令, 建议表单/工具}`。 - 渐进披露:**name + 何时用** 进 system prompt(像 L1 一样便宜);`load_skill(name)` 工具按需注入完整指令。 - 接替旧场景人设的「领域指导」,但变成**可加载模块**(与取消多智能体一致)。 ## 7. 鉴权与授权模型 **凭证透传(不变)**:用户 token 登录 ERP 后存浏览器;前端每请求带给 xlyAi → xlyAi 工具转发进 ERP API 的 `Authorization` 头;只在单次请求上下文临时持有,不长期存;**token 绝不进 prompt / LLM 可见文本**;基础设施凭证(Ollama/Milvus/DB 池)与用户凭证分离。 **token 生命周期 = ERP web 会话(已查实 2026-07-21)**:xlyAi 转发的 `authorization` 就是 ERP 登录 token(前端逐请求传入 → `XlyErpService`/`session.setAuthorization` → 调 ERP 的 `Authorization` 头;xlyAi 自带的 `RedisTokenManager` 是**注释死代码**,不自造 token)。**长效、非一次性**,过期即 ERP 会话过期。⇒ (a) ERP API 返鉴权失败时,xlyAi 向对话框推**「登录过期」事件**(与网页一致);(b) **会话状态按稳定身份(userId+sBrandsId+sSubsidiaryId)+ conversationId 存,不按 token 存** → 重登录/换 token 后凭同一身份接回历史。**autonomy = 永远用户在场触发**(已定)→ 无需服务账号/委托令牌,链式续跑只在用户回到会话时发生,凭证模型最简。 **⚠️ 授权:后端不是权限权威(2026-07-21 调查证实)** - ERP 后端 `getBusinessDataByFormcustomId` + **所有写/动作端点(add/update/delete/审核 doExamine)只验登录 `@Authorization`**;逐用户表单/菜单权限 `checkByUser` **被故意注释**(`// 朱总说不用放 20230626`)。 - 后端**只强制**:公司级租户隔离(`sBrandsId/sSubsidiaryId`)+ 行级 `jurisdiction` 数据范围。 - 用户的表单/菜单权限是 **UI-only**(前端按 `sAuthsId` 过滤菜单)。**agent 直接打 API 绕过 UI → 能碰用户界面里看不到的表单** = 越权放大。 - 读 API 还能被写:通用 API 把请求体任意 key 按名绑定到过程 IN 参、无白名单 → `SP_Inventory_InOutWarehouse`(材料库存台账,AI 已暴露)在 `bUpdate=1` 时真改库存表。 **ERP 授权本有三层,实际只跑两层**:租户隔离 ✅ / 行级 jurisdiction ✅ / **表单-菜单级权限(`sAuthsId`)❌ 被注释、仅前端 UI 用**。第三层的**数据仍在**(`sAuthsId` 记录每用户授权的表单)——所以可在 agent 侧补回。 **⇒ 新增架构组件:xlyAi 侧授权层 = 表单级白名单。** 读 `sAuthsId` 把 agent 限制在用户**实际有权的表单/动作**集内,**Read/Query/Invoke 共用这同一个边界**。**只做表单级、不做字段级**(表单内所有列可见——2026-07-21 决定)。方案 **B(xlyAi 强制,推荐)** vs A(重开后端 `checkByUser`)vs C(两者)。 - **Read 参数白名单**:只传已知过滤/分页参数,**绝不透传 `bUpdate/bUpdateAll` 等**(否则读变写)。 - **Query 例外**:直连 DB 无任何后端兜底 → xlyAi 强制注入租户 + 视图白名单(见 §9)。 - **Invoke/写动作**:后端零权限校验 → 必须 xlyAi 授权 + 走 ProposeWrite 确认门,**绝无绕过确认门直接触发 ERP 写动作的路**。 ## 8. 上下文管理 单 agent;L1 进 prompt 做路由;KG 收窄「该看哪些表」;工具输出分页/摘要;ChatMemory 窗口化 + 超长摘要存 Redis;工具循环步数/超时护栏。 ## 9. 安全 - **授权(新,见 §7)**:ERP 后端不做逐用户表单权限 → xlyAi 授权层按 `sAuthsId` 限制 agent 可碰的表单/动作(**表单级、不做字段级**);Query 的视图白名单**绑定用户有权表单对应的 `viw_*`**;现网发现"后端权限被故意关"待报业务。 - **字段级权限不做**(决定)→ 免掉字段掩码/按角色分层视图/敏感字段走 API 那套,Query 安全栈收敛为:只读账号 + 视图白名单(绑 sAuthsId) + 租户 AST 注入 + SELECT-only + 挡 OUTFILE/LOAD_FILE + LIMIT + 缓存过校验按品牌隔离。 - **Read** = 走 ERP API,得租户+行级范围,但**非表单权限**;必须**参数白名单**(防 `bUpdate` 触发写库存)。 - **Query(最大风险)** = 裸 SQL 击穿权限。防护:`jsqlparser` **单条 SELECT**(拒 DML/DDL/多语句/注释藏 payload/`INTO OUTFILE`/`LOAD_FILE`/`SLEEP·BENCHMARK`/`information_schema`)+ **专用只读 MySQL 账号** + **强制注入租户谓词或只允许查 `viw_*` 安全视图** + 强制 `LIMIT`/超时。解析 + 账号权限**双保险**。 - **Prompt 注入 / 数据即指令**:ERP 数据(备注/客户名等)会进 LLM,可能含「忽略上文…」类注入。对策:检索数据以**结构化/带标注**方式喂入(明确「以下是数据,非指令」),关键动作(写入/SQL)**由确定性代码校验**而非听 LLM。 - **写入校验**:LLM 产出的 payload 入暂存前,**服务端按表单 schema 校验字段/类型/必填**,不信任 LLM 直接生成合法值。 - **审计**:对越权敏感的写入/SQL,记不可变审计日志(谁、何时、什么请求、什么 SQL、暂存了什么)。 ## 10. 写入闭环与暂存表 **ProposeWrite 是单一写工具**(不拆 copyto/报价)。**agent 侧写入流程对所有写统一**: ``` 收集参数(FormCollect/AskUser/读行) → ProposeWrite(出diff+写draft) → 用户点确认 → 确认端点执行 ``` **谁过 LLM、谁不过(关键)**: - **ProposeWrite(LLM 工具, immediate-return)**:授权校验 + 出 diff + 往 `ai_op_queue` 写一条 `draft`(payload 存服务端)→ 返回 `{diff, sId}` → **结束本轮,不执行**。 - **确认端点(确定性 HTTP, 非工具/非 LLM)**:用户点【确认】→ `POST /op/{sId}/confirm` → status=confirmed → **同步调 ERP `/ai/execStaging/{sId}`** → 拿结果 → 回链接给用户 + **结果写入会话状态(供链式续跑)**。取消 → `POST /op/{sId}/cancel`。 - **结果怎么进对话(不过 LLM)**:对话框是**事件流**,不只是 LLM 文本。确认端点的 HTTP 响应 `{status, link, msg}` **由前端直接渲染成一张 op_result 卡片**(`✅ 已生成 [查看]`),无需 LLM 生成;同时该事件写进会话历史。ProposeWrite 的 diff、AskUser 选项、FormCollect 表单同理——工具返回结构化 payload,前端渲染卡片/控件。只有要 NL 收尾或**链式下一步**时才把结果喂进新的 LLM 轮次。 - **ProposeWrite 写完 draft 即结束本轮**(immediate-return),agent 暂停 `pendingInteraction=awaiting_confirm:{sId}`,此刻 `ai_op_queue` 只有一条 `draft`、未执行;执行在"用户确认"独立事件里,链式则 resume 新一轮;久不确认按 `tExpireAt` 过期。 - **execStaging 不进任何工具、不新增工具** —— 它必须在"用户确认之后"这个独立事件里触发,塞进 immediate-return 的 ProposeWrite 会变成"确认前就执行"。工具仍是那 6 类。 - 报价即使已用 FormCollect 填完表单,**落暂存前仍要一份对话内最终确认**(确认 = 允许存起来的门)。 - 落暂存后是 **ERP 的事、与 agent 无关**:ERP 对不同待办的策略(报价自动执行 `Sp_Ai_AddQuoQuoAfter` / copyto 让用户在 ERP 原生表单再确认→执行器执行)都在暂存之后。 - **ProposeWrite 泛化为"提议任意变更动作"**:ERP 按钮动作(审核/过账/生成 等**写按钮**)也走这道确认门;只读按钮(查询/导出)归 Read。⚠️ 绝不给 agent 一条绕过确认门直接触发 ERP 写动作的路(后端权限只管"能不能做",不等于"这次同意做")。是否需要独立的"按钮调用"底层能力,待后台调查(过程是否写、读 API 会否触发写、后端是否对表单/过程/动作调用鉴权)后定。 **通用双确认流(copyto)**:① 对话内 diff + 确认按钮 → ② `ProposeWrite` 落暂存 `sp_ai_addcommonwx` + 返回 ERP 链接 → ③ 用户进 ERP 原生表单(预填)再确认 → ④ **ERP 暂存执行器**排空并执行真实写入。 - **现状缺口**:现有 `sp_ai_addcommonwx`(239 行)**完全只为"生成下游单据"设计**(字段全是 源→目标 + 合并/分单模式),且**没有消费者**(`Sp_Ai_AddCommonAfterNew` 只入队从不执行)。缺 op 类型/目标记录id/payload/**用户·租户**/status/结果/自动标志/过期 → **覆盖不了 增/改/删/审核**。 **⇒ 新建通用暂存表 `ai_op_queue`**(旧表留给存量或迁移): ``` ai_op_queue( sId PK/深链token, sUserId, sBrandsId, sSubsidiaryId, sOpType, -- create|update|delete|examine|generate sTargetFormId, sTargetModuleId, sTargetTable, sTargetBillId, -- update/delete/examine 用; create/generate 空 sPayload JSON, -- 列→值(create/update) 或 动作参数(examine) sSourceRef JSON, -- generate: 源表单+选中 sSlaveId 集 bAutoExecute, -- ERP 自动执行 or 等用户提交 sStatus, -- draft|confirmed|executing|executed|failed|expired sResultBillId, sResultMsg, sErrorMsg, tCreateDate, tConfirmDate, tExecutedDate, tExpireAt) ``` `sOpType + sTargetBillId + sPayload` 统一覆盖所有写。报价=generate/quote+auto;审核=examine+目标id;改删=update/delete+目标id+payload。 **卡片三态状态机**:`处理中` / `待处理`(manual 终态) / `已完成·失败(重试)`(auto 终态)。用户点确认 → `/op/{sId}/confirm` → 卡片=**处理中**(不阻塞聊天,LLM 已停,仅此卡片转圈)。终态渲染由**前端**从响应或 SSE 推送拿,不过 LLM。 **auto 流程恒异步**(报价):confirm → **立即回 `executing`**(卡片=处理中,永不 HTTP 死等)→ ERP 检查暂存并**在一个事务里执行**该行 → **终态经 SSE 推送**(`executed+billId+link` 或 `failed+errorMsg`)→ 卡片渲染 `✅已生成[查看]` 或 `❌失败[重试]`,结果写会话。链接打开=**已建好的单据**。 - ⚠️ **原子**:execStaging 必须事务化,半途失败**自动回滚**,重试才安全。 - ⚠️ **幂等**:重试仅当 `status=failed`;`sId` 作幂等键,防"执行成功但响应丢失→重试→重复建单"。 - ⚠️ **终态落库 + 重连对账**:终态必写 `ai_op_queue.status/result`(SSE 只是实时推、非唯一真相);前端重连/刷新用 `GET /op/{sId}/status`(或"我的近期 op")对账,防跳变发生在断线时丢失。 **manual 流程**(新增/改/删/审核):confirm → ERP 只**校验暂存存在且有效**(失败/过期→失败卡片)→ 回 `link`(不执行)→ 卡片渲染 `待处理[去ERP操作]`。**xlyAi 到此终止,此卡片不再更新、ERP 不再回响应。** 链接打开=**字段已预填/预改的原生单据**,用户手动点 保存/审核/作废 才真正落库。 - ⚠️ **代价**:xlyAi 拿不到最终单据 id、不知用户是否完成 → **跨 manual 写不能链式**(终态交接)。auto 写可链式。将来要 manual 链式需 ERP 完成回调。 - manual 校验快,`/op/confirm` 可**同步**回链接直接进"待处理"(auto 才恒异步)。 - **两种链接**:`bAutoExecute=1`(报价)→ ERP 立即执行 → 链接 `/form/{formId}?bill={sResultBillId}` **打开建好的单据**;`bAutoExecute=0`(新增/大多数)→ ERP 只校验+备预填 → 链接 `/form/{formId}?staging={sId}` **打开预填表单,用户点提交/保存**(走 addBusinessData 才真正写)。 - **ERP 侧要新建**:① 预填加载 `/form/{formId}?staging={sId}`;② 执行端点 `/ai/execStaging/{sId}`;③ status/result 回写;④「我的 AI 待办」入口。 - **需处理**:幂等(重复确认)、过期、确认前底层数据变化、状态机、失败回执、审计。 ## 11. KG 集成 - **L1** `viw_kg_domain` → 渲染进 system prompt(路由地图)。 - **L2/L3** `viw_kg_edge_flow`/`viw_kg_form_neighbors`/`viw_kg_edge_ref`/`viw_kg_field_dict` → `KgSearch` 工具按需查(**L3 必须按词查询,不整表 dump**)。 - 表单卡片 `viw_kg_form_card` → 可选向量召回(先本地内存或 SQL 匹配,远端 Milvus 后置)。 ## 12. 复用 / 退役 / 新建 | | 内容 | |---|---| | **复用** | `AiServices` 循环、`ChatMemory`/`OperableChatMemoryProvider`、`VectorizationService`、表单 API 客户端、NL2SQL 护栏 + `ai_global_agent_question_sql` 缓存 + `ai_sql_error_history` 纠错、`jsqlparser`、Milvus/embedding | | **退役** | 8 场景 `SceneSelector`、`DynamicToolProvider` per-form `ToolMeta` 工具、手写 `explainMilvusResult` RAG(并入工具) | | **新建** | 6 类通用工具、取表单 schema 能力、Skill 注册表 + `loadSkill`、Redis 会话持久化、L1 prompt 注入、diff 确认端点、**ERP 侧暂存执行器 + 预填 + 深链 + 待办入口**、审计日志、Langfuse 接入;**(设计外补充)意图门 + 按意图收窄工具集 + 确定性槽位填充**(见 §17) | ## 13. 迁移路线(strangler,不大爆炸) - **P0 KG 底座** ✅(`sql/` 7 视图,分支 `kg-edge-flow`)。 - **P1 单 agent 骨架 + Read + Query(只读优先、安全先行)** ✅ 代码完成(含表白名单 + 租户注入)。 ❌ **灰度未做**:没有选定灰度场景,也**没有拿旧 64 工具跑过回归基线对比**。 - **P2 AskUser(QA) + KgSearch + Skill 注册表** ✅ 代码完成(`askUser` / `kgSearch` / `ai_skill` 3 条种子技能)。 - **P3 ProposeWrite + ERP 暂存执行器 + 预填/深链** —— xlyAi 侧 ✅;ERP 侧执行器/预填/待办端点 ✅ 已写并编译, 但**默认关闭**(`erp.exec-staging.enabled=false`,本地走直连),**生产路径未联调**。 - **P4 退役旧多智能体/元数据工具;接 Langfuse;报价统一走确认制** —— 退役 ✅(2026-07-27 精简:旧栈/milvus/tts/ocr 全删,见 §18.2);Langfuse ✅(配置开关,默认关);报价确认制待业务。 ## 14. 已定 / 开放问题(见对话讨论) **已定(2026-07-21)** - ✅ **写入需链式 → 必须跨会话状态**:写操作不是一次性。skill 可能"下单→据此生成入库",第二步依赖第一步**已执行**。⇒ 需 ERP 执行后**回调**通知 agent + **跨会话/长任务状态**(会话可在 ERP 执行完后 resume)。这超出单会话 ChatMemory,需要一个持久的「任务/流程实例」存储(Redis/DB)。 - ✅ **审计强制留痕**:金融类数据,记不可变审计(谁、何时、什么请求、什么 SQL、暂存/执行了什么)。审计独立于 Langfuse(LLM tracing ≠ 业务审计)。 - ✅ **token = ERP web 长效 token(已查实)**;会话状态按 `userId+conversationId` 存(非 token),重登录接回;过期向对话框推「登录过期」。 - ✅ **多条命名会话/用户**(会话列表侧栏;存储按 `conversationId` 分)。 - ✅ **前端 = 保留 xlyAi 独立聊天页**(`/xlyAi/chat`,演进 `templates/chat.html` + `PageController`)。 - ✅ **autonomy = 永远用户在场触发**(无服务账号/委托令牌;链式续跑在用户回到会话时发生)。 - ✅ **模型 = 指定模型(云/本地皆可)**,不因出网特殊处理,走配置。 - ✅ **部署 = 共库多品牌**(`xlyweberp_saas`,按列区分 `sBrandsId/sSubsidiaryId`)→ 租户隔离全局强制,KG/缓存/Query 一律按品牌切分。 - ✅ **代码全面改造、写入 `master`**;改造前已完整备份(xlyAi `backup/pre-rearch-20260721`@`ac85337`;saas 后端 `backup/pre-rearch-20260721`@`382e97b`,快照含工作区,运行中的前后端未动)。 **开放** 1. **Query 越权锁法**:仅 `viw_*` vs 注入租户谓词 vs 复制 ERP 表单级 ACL —— 待现有 NL2SQL 安全调查结论后定(倾向 A=`viw_*` + jsqlparser AST 注入租户)。 2. **ERP 侧暂存执行器 + 预填 + 深链 + 执行回调** 归谁做、能否排期(写入阶段 P3 跨团队最大单点风险;**M1~M2 不依赖它**)。 3. 模型多工具 tool-calling 可靠性 —— 跑起来实测,不行则换模型 / 加参数校验兜底(我自测)。 4. 多语言:默认仅中文,预留可扩展。 5. 报价改双确认(失去即时提交)需业务确认(写入阶段再定)。 6. **formId 解析**:靠 KG 上下文让 LLM 推理 → 先搭框架、之后补 KgSearch;过渡期表单名模糊匹配 + 不唯一则 AskUser。 7. 现网安全发现(后端表单权限被关 / NL2SQL 越权 / 读 API 可写)是否作为独立整改上报业务。 8. 首个灰度只读场景选哪个(建议高频只读轻后果,如库存 / 应收账龄)。 ## 15. 实现状态(2026-07-21,分支 `kg-edge-flow`)—— 历史快照,勿当现状读 > 下面记的是**当天**的状态。工具已在 §16/§17 变过(4 个 `propose*` 已合并成单个 > `proposeWrite(action=…)`)。当前清单见 §17。 **已建成、可运行、已验证**(xlyAi 全新单 agent 前后端,`/xlyAi/api/agent/*`): - **单 ReAct agent**:`AiServices` 流式工具循环(qwen3:14b `think(false)`),L1 域图进 system prompt,退役 8 场景路由(旧代码休眠未删)。 - **对话**:SSE 流式;**多命名会话 + Redis 持久化**(`RedisChatMemoryStore`,重启不丢)+ 侧栏。 - **工具(5)**:`findForms`(表单目录/KgSearch雏形) · `readFormData`(列表/计数) · `lookupRecord`(单记录全字段) · `queryData`(只读 SQL 兜底,jsqlparser+LIMIT+审计) · `proposeUpdate`(HITL 写:改字段)。 - **写入闭环**:`ai_op_queue` 暂存 → 提议不执行 → `write_proposal` 卡片 → 确定性 `/op/{id}/confirm` 执行(ERP `addUpdateDelBusinessData`) / `cancel`。确认前绝不落地。 - **审计**:`ai_audit_log` 记 propose/confirm/cancel/query。 - **ERP 接入**:`ErpClient` dev-login(admin/666666)拿真实会话 token;读 `getBusinessDataByFormcustomId`,写 `addUpdateDelBusinessData`(`column` 列表 + `handleType`)。 ## 16. 缺口补齐(2026-07-22 上午,分支 `kg-edge-flow`)—— 历史快照,勿当现状读 上一轮 §15 里「尚未做 / 被外部条件阻塞」的缺口,本轮全部补齐(含跨团队 ERP 侧)。均已编译通过(xlyAi mvn + ERP gradle)。 > 后续变更见 §17:`proposeCreate/Delete/Examine` 已合并进 `proposeWrite(action=…)`; > `collectForm` 的字段来源已从 `gdsconfigformslave` 改为字段字典/精选映射(后者只作兜底)。 **xlyAi 侧** - **授权 + token 透传(§7)**:新增 `AgentIdentity` + `AgentFactory` —— **按每次请求的身份新建 agent**,把透传的用户 ERP token 与「表单权限集」(`sAuthsId` → `AuthzService.grantedIds`)放进每请求的工具实例(规避 LC4j 流式工具在回调线程执行、ThreadLocal 不可靠的问题,即架构说的 per-call context)。前端逐请求带 `authorization`+身份;`ErpClient` 所有读写 **转发用户 token**;**用户 token 过期绝不用 dev(admin) 重登**(防静默提权)。dev-login 仅作本地兜底。 - **写:新增 / 删除 / 审核**:`proposeCreate`(自动补主键/必填)、`proposeDelete`、`proposeExamine` 三个 HITL 工具,均只暂存 `ai_op_queue` draft、确认端点才执行;确认转发用户 token。`ai_op_queue` 加 `sPayload/sSourceRef/bAutoExecute/sResultBillId/sErrorMsg/tExecutedDate/tExpireAt`。 - **FormCollect(§5)**:`collectForm` 从 ERP 表单元数据(`gdsconfigformslave`:字段/控件/必填/下拉/默认)取 schema → SSE `form_collect` → **xlyAi 聊天页内渲染表单**(无需 ERP 前端),提交拼回 `proposeCreate`。 - **AskUser(§5)**:`askUser(question, options)` → SSE `question` → 前端渲染可点选项片。 - **Skills(§6)**:`ai_skill` 注册表 + `SkillService`(name+何时用进 system prompt)+ `loadSkill` 工具(按需注入完整 playbook);种子技能:新建报价 / 月度对账 / 库存查询。 - **KgSearch(§5/§11)**:`kgSearch` 暴露 L2 邻居/流转(`viw_kg_form_neighbors`)+ L3 字段→表列(`viw_kg_field_dict`),按词查、截断。 - **稳健 NL2SQL(§9)**:`QueryTool` 加**表白名单**(`viw_*` + 表单数据源 + 字段字典表,用 jsqlparser `TablesNamesFinder` 校验 → 挡住 `gdslogininfo`/`sysjurisdiction`/`ai_op_queue` 等敏感表)+ **单表租户谓词注入**(`sBrandsId`)+ prompt 品牌提示 + 自修复重试。 - **Langfuse(§1)**:`TracingChatModelListener` 增加**配置开关的 ingestion 导出**(generation span,走 JDK HttpClient,不引依赖);`docker-compose.langfuse.yml` 自托管;默认关闭。 > ⚠️ `docker-compose.langfuse.yml` 在**本机工作区存在但未入库**——被全局 gitignore 的 `**/docker-compose.*.yml` 规则挡掉了, > 换机器/换人 clone 拿不到(见 §18)。 **ERP 侧(saas-8s+ `release/customer/2025/saas-8s+`,backup 分支未动)** - **暂存执行器 + 预填 + 待办(§10)**:`AiStagingController @/ai` —— `execStaging/{sId}`(POST,执行)、`staging/{sId}`(GET,预填加载供原生表单深链)、`myTodos`(GET,AI待办);全部 `@Authorization`+`@CurrentUser`。`AiStagingService(Impl)` 读共享库 `ai_op_queue` → 以**用户身份**走 ERP 自己的写入/审核逻辑(`addUpdateDelBusinessData` / `doExamine`,复用租户隔离、单号、审核存储过程)→ 幂等 + 回写状态/结果。xlyAi 确认端点按 `erp.exec-staging.enabled` 委托它(生产路径)或直连(本地默认)。 **仍属业务/运维决策(非代码缺口)** - 现网安全发现(后端逐用户表单权限被注释关 / 读 API 可被 `bUpdate` 触发写 / 旧 NL2SQL 越权)——`docs/security-findings.md` 已成文,**是否整改由业务定**。授权层真正“收紧到非管理员”只有在**生产以真实用户 token 运行**时才能观测(本地 dev-login=admin 全通)。 - 报价改双确认、首个灰度只读场景选型 —— 业务确认项。 ## 17. 实现状态(2026-07-23,分支 `kg-edge-flow`,HEAD `8b927e3`)—— 历史快照,勿当现状读 > 这是**唯一的现状节**。改代码后请更新这一节,不要再往 §15/§16 里加。 ### 17.1 工具清单(9 个 `@Tool`,对应 §5 的 6 类) | 类(§5) | `@Tool` 方法 | 实现类 | 单例 / 按请求 | |---|---|---|---| | Read | `readFormData` · `lookupRecord` | `ErpReadTool` | 按请求(带身份) | | Query | `queryData` | `QueryTool` | 按请求 | | KgSearch | `findForms` · `kgSearch` | `KgQueryTool` | 单例(全局元数据) | | AskUser | `askUser` | `InteractionTool` | 单例 | | FormCollect | `collectForm` | `FormCollectTool` | 按请求 | | ProposeWrite | `proposeWrite(action=create\|update\|invalid\|cancelInvalid\|examine\|cancelExamine\|delete)` | `ProposeWriteTool` | 按请求 | | (非工具) | `loadSkill` | `SkillTool` | 单例 | **§16 之后的变化**:4 个 `propose*` 工具**合并成单个 `proposeWrite(action=…)`**(贴合 §10「单一写工具」), 并补上 `invalid/cancelInvalid`(业务单据的"删除"= 作废,可复原)与 `cancelExamine`(销审)。 ### 17.2 编排:意图门 + 按意图收窄工具集(§3 的实现修正) `AgentChatController.route()` 每轮: 1. **意图门** `IntentService.classify()` —— 受约束 JSON(`OllamaJsonClient`),产出 `{意图, 单据类型, 带角色实体, missing}`; 2. **确定性路由**: - **新增** → 完全**不走 ReAct**:`resolveMasterForm` → `SlotFillService` 按真实字段做正则/角色槽位填充 → 直接弹 `collectForm`; - **修改/删除/审核** → `extractWrite` 抽 `{record, field, newValue}`;齐了就**确定性直调** `proposeWrite`,缺了就问一次即停; - **查询** → WRITE 之外的 READ-scope agent(5 个读工具); - **不清楚/意图门失败** → FULL-scope 兜底(全部 9 个工具)。 3. `AgentFactory.build(identity, scope)` 按 `ToolScope` 组装工具 + 对应版本 system prompt; `maxSequentialToolsInvocations(8)` 作循环护栏。 **动机(实测)**:qwen3:14b Q4 在一次 ReAct 里同时判意图/选工具/编参数,会把"查询"当"新增"、 把产品名(纸盒)塞进客户字段、反复追问同一问题。窄任务(意图分类、尺寸拆分)改用受约束 JSON + Java 正则后稳定。 ### 17.3 写入闭环(真实落库,已端到端验证) - **报价 = 跨表主-从建单**:`proposeQuote` 把 17 个精选字段路由到 `quoquotationmaster` + `quoquotationslave`(印刷/部件,`sParentId` 级联)+ `quoquotationmanyqtys`(多数量,一档一行), 外键名→id、按列类型强转、自动补主键/租户/制单人/单号(`BJD`+`YYYYMM`+序号), 以 `__tables__` 结构化 payload 存 `ai_op_queue`,确认端点走 `ErpClient.createMulti` 一次提交。 **核价(控制/材料/工序行)刻意留给 ERP**——那是引擎生成的。 - **表单渲染带类型**:`collectForm` 字段带 `fkselect|select|number|date|text`;外键下拉由 `GET /api/agent/form/options?table=&q=` 从来源表实时取(只允许在字段字典里作为外键目标出现过的表,租户过滤 + LIMIT)。 - **验证记录**(真实写入本地 saas 库):`BJD202607082`(主表)、`BJD202607084`(主+从+多数量)、`BJD202607086`(含多数量 2000/4000/6000)。 ### 17.4 与设计的已知偏离 | 设计 | 实际 | 影响 | |---|---|---| | §4 immediate-return 工具 | **未用**(新 agent 没设 `immediateReturnToolNames`) | 提议 JSON 会回喂 LLM,可能谎称"已完成",目前只靠 prompt 规则压制 | | §4 `pendingInteraction` 状态机 | **未建**,靠 `ChatMemory` + 表单提交时消息里的 `proposeWrite(action=create)` 标记 | 多轮 HITL 无显式状态,跨轮续跑脆弱 | | §10 卡片三态 + auto 异步 + SSE 终态推送 | **恒同步**:确认即执行并同步返回终态 | 长事务会 HTTP 死等;无重连对账 | | §10 manual 预填深链 `/form/{formId}?staging={sId}` | ERP 侧端点已写,xlyAi 侧**不产生这类链接** | manual 流程未打通 | | §3 agent 完全自路由 | 前置确定性意图门 | 见 §17.2(有意为之) | ## 18. 未完成清单(2026-07-23)—— 历史快照,勿当现状读 **阻塞上线** 1. **`kg-edge-flow` 未合并 `master`** —— §14 已定"写入 master",但全部 6200 行新代码只在该分支;`master` 仍是改造前状态。 2. ~~旧栈未退役且仍在跑~~ ✅ **已退役(2026-07-27 精简)**:删除 `XlyErpService`/8 场景路由/`DynamicToolProvider`/Scene·ToolMeta 缓存、整个 milvus/tts/ocr 包、`/api/tts` 与 `/api/ocr` 端点、tts.html 及 chat.html 死 JS;pom 依赖同步裁剪(milvus/embeddings/mybatis/ocr/webflux 等)。代码只剩新栈 ~40 个类。 3. **零自动化测试**:无 `src/test`,每次都是手工跑对话验证;重构无回归网。 4. **生产路径未联调**:`erp.exec-staging.enabled=false`;ERP 侧 `AiStagingController` 只编译过、没被真实调用过。 5. **授权层未在非管理员下验证**:本地 dev-login=admin 全通,`AuthzService.grantedIds` 的收紧效果无法观测。 **功能缺口** 6. `pendingInteraction` 状态机 + immediate-return(§17.4)。 7. 异步执行 / 卡片三态 / SSE 终态推送 / `GET /op/{sId}/status` 重连对账(§10)。 8. manual 预填深链与「我的 AI 待办」在 xlyAi 侧的入口。 9. `ai_op_queue` 的 `tExpireAt` 过期清理无调度任务;`bAutoExecute` 建了但没有分流逻辑。 10. **报价核价结果不回读**——建完单不知道算出多少钱,用户得自己去 ERP 看。 11. Skill 只有 3 条种子且未在真实对话里验证过;`loadSkill` 无使用数据。 12. KgSearch 的**向量召回未接**(只有 SQL 关键词匹配)。2026-07-27 精简已把 milvus/embedding 代码与依赖整体删除——若做 5b 需按新栈重新接(milvus-sdk + embedding 依赖重加,旧实现在 git 历史 `8b927e3` 前可查)。 13. 报价之外的单据**没有精选字段映射**,`businessFields` 走字段字典启发式,复杂单据大概率不准。 **安全 / 运维** 14. Query 的租户 AST 注入**只覆盖单表**,多表 JOIN 仅靠 prompt 提示;**只读 MySQL 账号未启用**(与应用共用连接池)。 15. `docker-compose.langfuse.yml` **未入库**(被全局 gitignore `**/docker-compose.*.yml` 挡掉);Langfuse 默认关闭、从未真正导出过 trace。 16. 现网安全发现待业务决策(见 `docs/security-findings.md`)。 **业务决策项** 17. 首个灰度只读场景选型;报价是否改双确认;多语言(当前仅中文)。 ## 19. 实现状态(2026-07-28 上午,分支 `agent-dev` → `agent-main`)—— §19.1/§19.2 的编排与记忆已被 §20 重构取代;§19.3-19.5 的护栏、协议与安全加固仍是现状 2026-07-27/28 全量重构(22 项待办,5 批),全部完成并验收。 ### 19.1 工具与编排(§3/§5 的最终形态) - **固定 6 工具**,不再按意图分范围(`ToolScope` 已删):`findForms` / `readFormData(formId, moduleId, keyword?, page?)` / `lookupRecord` / `collectForm` / `proposeWrite(action=…)` / `askUser`。n=50 基准显示按意图收窄无准确率收益(严格 76% vs 76%), 反而破坏 KV-cache 前缀稳定性。已删除:`queryData`(NL2SQL 栈 + jsqlparser 依赖)、`kgSearch`、`loadSkill`(SkillTool/SkillService;`ai_skill` 表保留)。 - **单一 system prompt**(`SystemPromptService.prompt()`):域地图 + 业务常识(问价=新建报价核价、删除=作废)+ 6 工具清单 + 4 条准则。 - **意图门 4 类**(查询/新增/操作已有单据/其他):提示只写**类定义**(含有界行业常识),零逐案纠错规则; 具体写动作(update/invalid/examine/cancelInvalid/cancelExamine/delete)由路由层从原话推出(`deriveWriteAction`)。 - 记录定位:`FormResolverService.searchField` 按关键词形态选检索字段(形如单号→`sBillNo`,否则名称列); `locateRecord()` 为 update/invalid/examine/delete 四路共用。 ### 19.2 上下文(§8 的实现) - **会话账本** `chat:ledger:{conv}`(Redis LIST,30 天):user/assistant/clarify/form/question/tool/proposal/confirm/cancel 全类型事件按序落账——确定性路径也入账;前端历史从账本重放(`ConversationService.history`)。 - **状态槽** `chat:state:{conv}`(代码写入,非模型总结):上轮意图/最近实体/在办单据(阶段)。摘要前缀喂意图门与写槽位抽取 (跨轮指代「它/那张单」可解),尾注入 agent 用户消息。 - **投影记忆** `ProjectedChatMemory`(替代 MessageWindowChatMemory):存储保完整(硬上限整轮裁剪), 读取按字符预算(≈token)从新到旧整轮取,旧工具结果压一行;当前轮原样保留。 - 确定性路径通过 `RedisChatMemoryStore.appendTurn` 补记忆(表单弹出/澄清/提议/确认结果),修补记忆空洞。 ### 19.3 可靠性护栏 - Ollama(0.32.3,已升级重测)对 `tool_choice=required` **不硬执行**(4/5)→ 代码护栏: 查询轮零工具调用且答案含数字 → 强制重试一次,仍复发则标注「未经核实」;非查询轮声称已生成/已完成但未真正 proposeWrite → 附纠正提示。 - `erp.exec-staging.enabled=true` 生产路径已本地端到端验证(改字段/新增报价/作废均真实落库、以用户 token 身份执行、 ai_op_queue 状态由 ERP 侧回写)。修复两个真实缺陷:xlyAi 侧未解 ERP 统一响应包装(status 在 `dataset.rows[0]`); ERP 侧执行器缺 `invalid` 分支(已补,走 `updatebInvalid`/Sp_Invalidation)。 ### 19.4 交互协议与前端 - **确定性表单提交**:`POST /api/agent/form/submit` 结构化字段直达 `proposeWrite(action=create)`,不经 LLM 再编码 (旧的 NL 标记路径已删)。 - `GET /api/agent/form/options`:多列(名称+按使用度选 3 个展示列)+ 名称搜索 + 分页,FK 二级选择器数据源。 - `askUser` 返回 `allowFree=true`:前端选项片之外永远保留自由输入(Claude Code 风格)。 - `chat.html` 整页重写:原生 JS(去 jQuery)、全视口、可折叠会话侧栏、单滚动区、markdown 表格、提议卡片、 collectForm 网格布局 + 类型化控件 + FK 选择器模态。 ### 19.5 安全加固(2026-07-28,外部审计后) 审计(21 agent / 641 工具调用)报出 5 个 Critical,**逐条复核全部成立**,已修复并回归。两个根因: **身份由客户端自报**、**所有失败路径 fail-open**。 - **身份唯一来源 = ERP 内省**:新增 ERP 端点 `GET /ai/whoami`(`@Authorization` + `@CurrentUser`), xlyAi `AuthzService.resolveIdentity(token)` 换取真实 userId/租户/用户类型;请求体与前端里的 `userid/brandsid/usertype` 全部删除(自报即可伪造)。 - **fail-closed**:`erp.dev-login.enabled` 变成真开关(默认 false,此前无任何消费者), dev 账号 `admin/666666` 之类的内置默认值清空;生产下空 token / 无效 token → 401, 绝不回落 dev(管理员) 身份。意图门失败也不再顺带关闭反编造护栏。 - **越权**:会话 id 按 userId 命名空间化 + 归属校验(他人会话 403); `/op/{id}/confirm|cancel` 需登录且必须是提议发起人;`/op/pending` 不再吐 `sPayload`。 - **并发**:confirm 用 `UPDATE … WHERE sStatus='draft'` 做 CAS 抢占,重复/并发确认只会执行一次; 单号在**执行前重新生成**,不再重放 propose 时的 MAX+1 快照。 - **租户**:FK 选项端点的租户改为取自内省身份(不再接受 `brandsid` 查询参数), `fkOptionPage` 拿不到租户时返回空而不是丢掉 `sBrandsId` 谓词。 - **凭据**:DB/Redis/LLM/ERP 连接信息全部改为环境变量(`DB_URL`/`DB_USERNAME`/`DB_PASSWORD`/`REDIS_*`/`LLM_*`/`ERP_BASEURL`), `allowMultiQueries=false`。⚠️ **历史提交里的生产库口令必须轮换**——删掉不等于失效。 - **数据正确性**:update 路径拒绝系统列、解析 FK、按列类型强转;数值强转对无法解析的输入**报错** (此前「一千」→0、「1,000」→1 静默写入),确认卡片展示的是**将要写入的值**(与 payload 同源)。 - **其它**:拼进 ERP URL 的 id 走 `safeId` 校验(阻断 query/path 注入);`ResponseStatusException` 保留 401/403 状态码,不再被全局兜底包成 200。 - **回归保护**:新增首批单测(10 个,`src/test`)钉住上述不变量;意图门 bench 复跑 50/50=100%。 **仍未做(需要环境/业务决策)**:nginx `/xlyAi` 前置的 ERP 会话校验(决定这些端点是公网可达还是内网可达)、 生产库 3318 是否对公网开放、xlyEntry 侧 `getBusinessDataByFormcustomId` 是否把 query 参数绑进存储过程 IN 参数、 以及审计其余 30 条中低危项。 ### 19.6 验收(2026-07-28) - 意图门 bench50:**50/50 = 100%**(终跑 49/50,唯一错例为 Ollama 502 网络抖动,单测复核正确)。 - agent 单步 bench50:严格 38/50 = **76%**(达标 ≥76%),宽松 45/50 = 90%。 - e2e 冒烟(本地 saas 栈 + exec-staging=true):查询+翻页 / 新增报价(表单→FK选择→确定性提交→确认→落库)/ 改字段(落库)/ 作废(按单号定位→落库)全部通过;UI 全流程浏览器实测通过。 ## 20. 当前实现状态(2026-07-28,分支 `rearch2`,自 `agent-main` 切出) 第二轮重构(rearch2):**废除意图门与确定性流水线,全部自由文本进唯一 skill-ReAct agent; 记忆改为事件日志唯一事实源 + 四段投影**。安全不变量层(§19.5)一步未退。 ### 20.1 编排:单 ReAct + skill(取代 §19.1 的门+路由) - `IntentService` / `Intent` / `SlotFillService` / `LlmJsonClient` / `OkHttpUtil` / `route()` / `handleCreate` / `handleWrite` / `deriveWriteAction` / `ground` / `withState` 全部删除。 理由:意图门不掌握字段定义,抽取值必然有损(「大16开」→「16开」类改写);原话是唯一信息载体, 只在字段定义存在的地方(工具内部)被解释一次。 - **固定 7 工具**:`useSkill` + 原 6 工具。流程知识做成 **skill 文本**(`resources/skills/*.md`, 文件名 ASCII(部署链 unzip 会搞乱中文文件名),首行=技能名,次行=一句话用途,其余=正文; 配 `xly.skills.dir` 指向文件系统目录即热更)。5 个 skill:查询数据 / 新建报价 / 新建单据 / 修改记录 / 单据状态操作(写作红线:只写步骤与停止条件、不写错例、值逐字照抄、出提议卡即停)。 - system prompt 模板化(`prompts/system.txt`:硬性要求 + 域地图 + 技能索引 + 7 工具一行清单 + 4 条硬规则), 基准脚本读同一模板与 skill 文件,杜绝复刻漂移。 - 尺寸拆分正则自 SlotFillService 迁入 `FormCollectTool.expandDimensions`(预填值里的 「50*30*5」「长和宽50cm,高5cm」确定性拆成 长/宽/高——模型拆不对是实测结论)。 - 反编造护栏**无条件常开**(原按意图开关):零工具调用且答案含数字 → 注入纠正话术重试一次 (internal 事件,前端不显示),复发标注未核实;WRITE_CLAIM 纠正与 `maxSequentialToolsInvocations(8)` 保留。 - 按钮端点不变且不经模型:`/form/submit`(结构化直达 proposeWrite)、`/op/{id}/confirm|cancel`(CAS)、 `/op/pending`、`/form/options`、会话 CRUD。 ### 20.2 记忆:事件日志唯一事实源(取代 §19.2 的三存储) - `chat:ledger:{conv}`(Redis LIST,rightPush 原子,30 天 TTL)升格为**唯一事实源**; `chat:mem`(整包读改写,有并发覆盖竞态)与 `chat:state`(三处手工同步)废除—— RedisChatMemoryStore 降级为旧会话只读兼容 + 删除级联,StateService 删除。 - 事件类型:`user`(internal?)/ `ai` / `tool_call`(**原样序列化载荷**)/ `tool_result`(全文+120字摘要)/ `form_submit` / `proposal` / `confirm` / `cancel` / `skill_active` / `skill_done`; 旧类型 assistant/clarify/form/question/tool 兼容渲染,自然淘汰。 - `EventLogChatMemory`(实现 LangChain4j ChatMemory):`add()` 逐条 append(用户事件由控制器先落账, 记忆层前缀去重;模型消息只由记忆层落账);`messages()` 读四段投影。 - `EventProjectionService` = **唯一渲染器**,前端历史与 LLM 上下文同源: ① 稳定 system prompt;② **进行中流程卡**(激活 skill 全文 + 在办单据状态,钉在 system 尾, confirm/cancel/新 skill 激活时摘下——skill 跨轮续办靠它,不怕旧轮工具结果被压缩); ③ 往事摘要区(预算外旧轮 → 确定性一行摘要,零模型调用); ④ 近期原文区(约 6000 字符按整轮纳入,旧轮工具结果压 120 字,**当前轮原样载荷精确重建**)。 - 「流式输出中途点确认」竞态从机制上消除(并发写者各自 rightPush,互不覆盖); 会话列表懒清理(内容键过期即剔除,3 天宽限)。 ### 20.3 基准与验收(`bench/`,已入库) - `bench50_v2.py`:冻结基线(2026-07-28 验收存档:意图门 100%、agent 单步严格 76%)。 - `bench_ext.py`:多步轨迹模拟(喂回固定工具结果,含生产反编造护栏复刻)+ 多轮用例 (消歧 S1-S5 / 多轮接续 M1,M2,M6 / 夹带 M3 / 长会话回指 M4 / 跨轮表单 M5 / 提议纪律 M7); `--fifty` 把 bench50 的 50 题按全轨迹判分,新旧架构同口径对照;`--arch new` 与生产同源读 prompt 模板与 skill 文件。 - 单测 29 个:投影正确性(当前轮保真/旧轮压缩/预算收拢/摘要区/internal 隐显/流程卡钉摘)、 并发 append 不丢事件、尺寸正则、skill 加载、以及 §19.5 的安全不变量回归。 - 验收数字见 `bench/bench_p3_acceptance.out`。 ## 21. 当前实现状态(2026-07-29,rearch3——写路径收权 + 记忆持久化 + 技能入库) > **本节是现状**;§20 的编排(skill-ReAct)保留,其记忆存储、写路径、技能存储被本节取代。 > 施工依据:memory `xlyai-suggestions-backlog`(#1-#7 + 施工顺序 A∥B→C→D)。 ### 21.1 写路径:模型全只读,AI 侧唯一的写 = ai_op_queue - **模型可用的 7 个工具全部只读/只渲染**:useSkill / findForms / readFormData / lookupRecord / collectForm(弹新增表单)/ **previewChange**(写操作预览卡)/ askUser。`proposeWrite` 退役。 - **previewChange(action, entity, record, fieldChinese?, newValue?)**(action:update/invalid/ cancelInvalid/examine/cancelExamine/delete;create 走 collectForm):定位记录 → 校验 (字段字典/FK 名称→id/类型强转/系统列拒改/**状态合法性**)→ 渲染预览卡(update=整表当前值+ 改动高亮、可在卡上继续修正;状态类=单据摘要+状态变化高亮,只读)→ 解析产物以 **previewId** 暂存 Redis(30 分钟 TTL,服务端绑定 userId/convId,客户端只见 id)。按钮文案 = ERP 同名 (保存/审核/反审核/作废/取消作废/删除)。 - **确定性保存端点**(不经 LLM):`POST /api/agent/preview/{id}/save`(fields=技术名→值, 空值=放弃该字段的改动)与 `POST /api/agent/form/submit`(create)。流程:归属校验 → 权限重查 → **重读记录重校验**(值/状态被他人改过 → 拒绝,"所见即所写")→ **原子 claim previewId**(防并发/ 重复提交)→ 写 `ai_op_queue` 一行 `pending+100`(多字段=多行、单事务)→ 落 `queued` 事件。 **xlyAi 到此为止**:执行/审计/权属全部移交 ERP 侧(执行器读本表;现状=报价自动执行、其余进待办)。 **状态协议 = sStatus 三态 + iStatusCode**(2026-07-29 定,码表见 `sql/ai_op_queue.sql` 头注): pending(100 已提交/101 ERP 处理中)、success(200/201 幂等命中)、fail(400 载荷/401 权限/404 不存在/ 409 状态冲突/422 业务校验/423 锁定/450 已取消/500 异常/504 超时);xlyAi 只写 pending+100, 其余全由 ERP 回写;旧值 draft/confirmed/executed/failed/cancelled 兼容读不再写。 - 退役:OpController(confirm/cancel/pending)、AuditService、ErpClient 全部写方法 + execStaging。 ErpClient 只剩 whoami / readForm / getUuid / checkBusinessData(dry-run 接口位)。 - **状态合法性硬检查**(预览+保存两次,防 TOCTOU;依据已核实的存储过程行为):审核要求未审核、 销审要求已审核、作废要求未作废、复原要求已作废;表无对应状态列则跳过。已审核单物理删除仅警告。 - **ai_op_queue 列名兼容**:新增 `sMakePerson`(ERP 惯例)与 `sUserId` 双写(ERP 执行器仍读 sUserId,切换后可删);迁移见 `sql/migrate_rearch3.sql`。 - 护栏:反编造重试与 `maxSequentialToolsInvocations(8)` 原样保留;WRITE_CLAIM 正则补 保存/录入并用负向前瞻排除「已审核状态」类状态陈述;"出过卡"标志改为 change_preview/form_collect。 ### 21.2 记忆:MySQL `ai_chat_event` 权威账本 + token 记账投影 - 账本 Redis→**MySQL `ai_chat_event`**(每事件一行、append-only 永不删;iId PK、iSeq 会话内序号 UNIQUE 防撞、iTurn 轮次——两者纯服务端溯源列,不进投影/API;sMakePerson/sBrandsId/sSubsidiaryId 按 ERP 惯例)。Redis `chat:ledger:{conv}` 降为**热缓存**(30 天 TTL);缓存失效冷读回源 (有界窗口)并回填;**缓存键已失效时新事件不单独重建残缺列表**。删除会话 = 清缓存 + 落 `deleted` 标记(冷读止于标记,历史永久可溯源但不复活)。 - `tool_call` 事件存**中立格式** `calls=[{id,name,args}]`(脱 LangChain4j 序列化耦合),旧载荷兼容读。 - 投影层 **token 记账**取代 6000 字符:本地保守估算(CJK≈1字/t、其余 3 字符/t,故意高估); 预算基数 = `llm.context-length`(镜像 Ollama OLLAMA_CONTEXT_LENGTH=16384,OpenAI 兼容通道 无法逐请求传 num_ctx)− 输出预留 − 工具 schema 定额;四段让位顺序 ⑤→④、①②与当前轮永不让位; **轮内让位**(当前轮超限:最早工具结果降 120 字摘要、保最近 2 个全文、单条 2400t 硬顶、配对不破坏); **发送前自检**(总量超预算再收缩,绝不让 Ollama 静默截头);④ 摘要区按 token 预算装载; LLM 窗口截断的孤儿 tool_result 丢弃。校准回路:`prompt_eval_count` vs 估算+定额,超出即告警 (TracingChatModelListener)。 - 流程卡新语义:previewChange 预览行("尚未写入");`queued` 后技能线摘下,待办行**只读**展示 `ai_op_queue.sStatus` 进度(confirmed/executing/executed/failed),直到下一个流程事件替换。 ### 21.3 技能:ai_skill 表存在开关 - 新表 `ai_skill`(sId/sName UNIQUE/sBrief/sBody/bEnabled/sUpdatePerson/tCreateDate/tUpdateDate; 旧草稿表 DROP 重建)。**切换开关 = 表是否存在**:存在→只读 DB(空表=无技能场景,绝不回读包); 不存在→war 包 `skills/*.md`;DB 故障→降级无技能+告警(绝不静默换回出厂版)。零技能优雅降级 (索引空渲染、useSkill 答无可用、裸工具正常聊)。 - 开发工作流文件为主:改 `skills/*.md` → `uv run --no-project sql/gen_ai_skill_sql.py > sql/ai_skill_seed.sql`(幂等 ON DUPLICATE KEY,按 sName 重置为文件版、bEnabled 保留, 超 600t 拒绝)→ 注入 DB。技能索引本就每次动态拼接,无需新机制。 ### 21.4 安全(红线全保留 + 本轮审计修复) - 保留:whoami 内省 fail-closed / 权限白名单进每请求工具 / token 不进 prompt·账本·预览草稿 / {userId}: 会话隔离 / safeId URL 防注入 / canRelogin 不静默提权 / FK 解析强制租户谓词。 - 新端点复刻同套检查:save 前归属+权限+重校验三层;previewId 122 位随机+格式校验+一次性。 - 本轮审计修复:readFormData 鉴权改用**表单目录反查的权威 moduleId**(堵"无权 formId+有权 moduleId"混搭);`/form/options` 增加"FK 表须被用户有权表单引用"检查(堵无表单权限翻名录)。 - 已知残余(交付说明):ai_skill 表内容=system prompt 注入面(需收紧写权限);dry-run fail-open (依赖 ERP 执行时真校验);ai_chat_event 永久保留需定保留策略;?token= URL 通道与 CORS 全开 为本地/壳层现状。 ### 21.5 待 ERP 侧(D 阶段对接) - `/business/checkBusinessData` dry-run 端点在 ERP 仓库**未提交**(他人负责);xlyAi 已留 `erp.dry-run.enabled` 开关 + `ErpClient.checkBusinessData`,完工后开启即接入(update 载荷 与入队同口径;__tables__ 多行分组形状需与 ERP 对一次)。 - doExamine/updatebInvalid 的 ERP 侧校验补做中;xlyAi 预览暂以直读 bCheck/bInvalid 实现硬检查。 - ai_op_queue `sUserId` 列:ERP 执行器切到 sMakePerson 后删除。