Skip to content

架构总览 ​

适用端:后端 + Web | 类型:机制

定制功能

enlev-module-agent 整个模块都是本项目自研,上游 yudao 没有对应物。 它不是「在 enlev-module-ai 上加了几个类」,而是一个独立的 Maven 模块—— 这个选择本身是有理由的,理由写在 enlev-module-agent/pom.xml:15-24 的 <description> 里,本页第一节逐字引用。

本页是 03-agent 这一组的入口页:它只讲整个模块共用的架构前提 (模块边界、身份与租户、声明值 vs 生效值、架构测试、配置面、依赖钉版), 具体机制分散在后面七页。如果你只想改一个工具或加一条校验规则, 可以先去 工具体系 或 Plan 与人工确认; 但动手前值得读完本页的「声明值 vs 生效值」与「架构测试」两节—— 那两条是全模块共用的纪律,踩了会被测试撞红。

定位:说清「这个模块为什么长成这样」——边界画在哪、身份怎么跨线程、 哪一层在管安全、哪些结论是被测试钉住的而不是靠约定。 以及一份实测出来的规模与形状盘点,供你判断「我要改的东西大概在哪」。

前置阅读:后端架构与模块依赖(全仓模块划分与依赖方向)、 权限体系总览(三层权限,本页反复引用它的 fail-open 语义)。

它解决什么问题 ​

为什么是独立模块,而不是并进 ai ​

pom.xml 的 <description> 把理由写成了可执行的约束,逐字如下:

xml
AI 助手模块:工具体系、Agent 循环、Plan 预览确认与权限网关。

为什么独立成模块,而不是并进 enlev-module-ai:
ai 模块的提交全部来自上游且仍在活跃维护,agent 代码若与它同目录交织,
每次 merge 都要人工判断。独立成模块后,对 ai 的改动只有「新增 api/ 目录」这一处。

依赖方向是单向的:agent → ai.api / infra.api / bpm.api / system.api,
agent 是唯一的聚合层,不被任何模块反向依赖。

这里有两个可以当场验证的断言,本页都验了:

  1. 「对 ai 的改动只有新增 api/ 目录这一处」——成立。见 ai 域开发。
  2. 「不被任何模块反向依赖」——成立,而且是实测的: 全仓 grep -rln "import cn\.enlev\.ainb\.module\.agent" --include=*.java 排除 agent 自身目录后命中 0 个文件;pom.xml 里引用 enlev-module-agent 这个构件的 也只有 3 处——它自己的 pom、enlev-server/pom.xml(启动装配)、根 pom.xml(reactor 声明)。 ⟹ agent 是严格的叶子模块:它能调四个上游域,没有任何东西能调它。

「只走 api/」不是分层洁癖,它是一条安全结论

AgentArchitectureTest#testOnlyDependOnApiLayer(:256-303)的注释里写得很直白: 权限边界清单第 7 条(「让 AI 查询用户无权查看的数据 → 行权限生效」)当前验不出东西, 因为压根没有任何工具能拿到业务行数据——而这不是碰巧,是因为 agent 只能调其它模块的 api/ 层, 而那一层至今没有暴露任何返回业务行数据的方法。

所以这条测试挡的不是耦合,是「有人给 agent 开一条直连 Service 的口子, 而没人意识到那等于绕过了行权限」。攻击面一旦开出来,第 7 条立刻从「不可达」变成「未覆盖的真实风险」—— 因为行权限在拿不到 userId 时是放行的(DataRowFilterResolver:171-173), 字段权限同样(PermissionFieldHelper 类注释第 30 行「无登录上下文 = 不限制」), 两者都只打一条 warn。

规模与形状(2026-10-25 实测) ​

维度数量备注
main .java298合计 61,041 行
test .java161合计 62,840 行、2,225 个 @Test
@RestController15见下文「接口清单」
错误码67全在 enums/ErrorCodeConstants.java(876 行)一个文件里
数据表16与 16 个 *DO.java 严格 1:1,无孤儿表也无孤儿 DO
@ConfigurationProperties8全在 framework/agent/config/(同目录还有一个 @Configuration 的 AgentConfiguration,不在这 8 个里)
定时任务3job/ 下三个类
诊断端点4 个 Controller735 行,见「坑与注意」

测试代码比生产代码还多(62,840 vs 61,041)。这不是凑数: 后面几页会反复看到「一条禁令配一条变异探针」的写法,那是刻意的双倍投入, 理由见下文「架构测试是这个模块的宪法」。

这些数字怎么来的

find ... -name '*.java' | wc -l 与 wc -l 直接数,@Test 用 grep -c。 不是从任何设计文档抄的——设计文档里的同类数字已经漂移过(例如早期方案写「~58,549 行」, 上一版本页记的是 58,845,本次实测 61,041)。

🔴 这张表每次后端提交都可能变,不要把它当常量引用。本次相对上一版的差额可以逐条归因: main +2 文件 / +2,133 行与 test +4 文件 / +3,061 行 / +118 个 @Test 来自 476b4b2637(补 ScriptSandboxTest、AgentToolCatalogTest、DataModel{Create,Update}ValidatorTest、 FormSaveDraftToolTest 等)与 1b1ac7b1e9(补 AgentDocMarkdownSanitizerTest、 AgentDocSyncServiceImplTest、AgentPromptBuilderTest);错误码 65 → 67 与诊断 Controller 721 → 735 行同样出自 1b1ac7b1e9(新增 /agent/diagnostic/doc-sync-status、doc-sync-verify 两个端点与配套错误码)。再往后 main +63 行 / test +88 行 / +2 个 @Test 出自 4093e19c7d (doSync 的「语料为空就别删」守卫 + 两条对应单测,文件数不变)。 Controller 数、表 ↔ DO、@ConfigurationProperties、job 数未变。

数 @Test 要加行首锚定:grep -ro '@Test' 在 agent 上会多数 1 个(javadoc 里的提及)。

代码在哪 ​

后端根路径缩写 agent/ = enlev-module-agent/src/main/java/cn/enlev/ainb/module/agent/。

顶层目录 ​

目录文件数职责详见
agent/framework/114全部机制层:引擎、工具、校验、清单、安全、提示词下面细分
agent/controller/5715 个 Controller + 各自的 VO接口清单
agent/service/41编排与落库各专题页
agent/tool/36@AgentTool 标注的内置工具实现工具体系
agent/dal/3216 个 DO + 16 个 Mapper数据模型
agent/enums/12错误码、场景枚举、风险级等—
agent/job/3保留期清理 + 两个调度器工作流引擎
agent/listener/1⚠️ 见下面的警告—

framework/agent/core/ 细分 ​

子包文件数职责
core/engine/33DSL 编译(compiler/ 3)、节点执行器(executor/ 12)、DSL 模型(dsl/ 7)、触发器(trigger/ 3)、支撑(support/ 3)、上下文(context/ 2)、trace / io / config 各 1
core/manifest/27变更清单与预演(ChangeManifestDryRunner 1122 行)
core/validate/15DSL 校验(WorkflowDslValidator 5800 行)与表单 schema 校验(981 行)
core/tool/10注解、注册表、执行器、风险级、目录(AgentToolCatalog,476b4b2637 新增)
core/chat/7提示词装配、答案切分、建议解析
core/security/4OutputModerator、UntrustedInputSanitizer、PromptDataFence、AgentWebhookRateLimiter
core/loop/3agent_loop 节点的两道闸与提示词
core/gateway/2AgentSecurityGateway + AgentExecutionContext
core/react/ core/plan/ core/explain/ core/copy/各 1ReAct 图、Planner 提示词(1170 行)、节点树解释、复制改写
framework/agent/config/98 个 @ConfigurationProperties + 1 个 AgentConfiguration
framework/agent/core/AgentTaskKeys.java1图 state 的键名常量

framework/ 不是一个「无 I/O 的纯机制层」

很容易按名字推断「framework = 不碰数据库」。实测不成立: framework/ 下有 10 个文件直接 import 了 dal.mysql 或 dal.dataobject:

文件import 处数
core/validate/WorkflowDslValidator.java3
core/engine/trace/NodeTraceRecorder.java2
core/engine/trigger/FormDataChangedTriggerListener.java2
core/chat/AgentPromptBuilder.java1
core/engine/compiler/DslToStateGraphCompiler.java1
core/engine/trigger/WorkflowTriggerExtractor.java1
core/engine/trigger/WorkflowTriggerInvoker.java1
core/gateway/AgentSecurityGateway.java1
core/manifest/WorkflowChangeManifestBuilder.java1
core/plan/WorkflowDslPlannerPrompt.java1

其中 AgentSecurityGateway 注入的是 AgentConfigMapper(第 4 步查租户总开关), WorkflowDslValidator 要查执行史来决定某条规则是拦还是降档。 这些都是合理的,但结论是:给 framework/ 写单测时不能假设它无依赖, WorkflowDslValidator 的测试就必须 mock Mapper。

listener/ 这个目录名会骗人

agent/listener/ 下只有 1 个文件(AgentBpmApprovalListener.java), 但本模块实际上有 2 个事件监听器——另一个是 framework/agent/core/engine/trigger/FormDataChangedTriggerListener.java。

按目录名找监听器会漏掉一半。别为了「整齐」把它挪进 listener/: 它属于工作流触发器那一套,与同目录的 WorkflowTriggerExtractor / WorkflowTriggerInvoker 是一个整体,挪走反而拆散了。

最大的几个文件 ​

行数文件一句话
5800core/validate/WorkflowDslValidator.javaV 系列全部 DSL 校验规则(34 个号),是第 2 名的 3.6 倍
1607service/chat/AgentAssistantServiceImpl.java四条路由的装配与落库
1363core/engine/compiler/DslToStateGraphCompiler.javaDSL → Spring AI Alibaba Graph
1258service/workflow/AgentWorkflowDefServiceImpl.java定义 CRUD、发布、版本
1232core/plan/WorkflowDslPlannerPrompt.javaPlanner 提示词装配(core/plan/ 的唯一文件)
1177tool/impl/form/FormUpdateTool.java表单更新工具
1122core/manifest/ChangeManifestDryRunner.java变更清单预演,全模块唯一允许查库的清单类

5800 行的校验器是刻意的,不要拆

WorkflowDslValidator 一个文件装了 V 系列全部规则。看着该拆,但拆了会破坏两件事:

  1. 号段的权威只有一份。前端 PublishGateDialog.vue 的注释明写 「号段与生效条数的权威只有一份,在后端 WorkflowDslValidator 的类 javadoc 里」。 拆成 34 个类,这句承诺就没有落点了。
  2. 架构测试按目录扫。DECLARED_RISK_ONLY_DIRS 钉的是 framework/agent/core/validate 这个目录,拆到别的目录会静默脱离扫描域 (而 testDeclaredRiskOnlyOnGatePaths:359-362 只在「整个目录空了」时才撞红, 拆走一半它是绿的)。

要加规则就往这个文件里加,加完去号段表登记。

⚠️ 但那份「权威」不在类 javadoc 里——前端那句注释记错了位置。实测它在 WorkflowDslValidator:5349-5441, 也就是埋在 V20(validateChatLegsWriteReply)自己的 javadoc 里(本页上一版记的 :4903-4993 已随文件增长漂移了 400 多行)。号段口径本身是 V1~V16 + V18~V35,共 34 个号;V17 结构上不存在;下一个可用号是 V36(:5361 自己钉的)。 完整论证与「为什么全文 grep 必然多数」见 Plan 预览确认与 HITL · V 号段。

🔴 号段是活的:V33 / V34 / V35 是本页上一版之后才补的,「下一个可用号」也随之从 V33 变成 V36。 引用号段前先按它自己规定的数法重数(:5399-5406:数 section 分隔条 // ==== …(V..)==== 与各规则 javadoc 标题里的「本条是 Vxx」),别拿全文 grep 'V[0-9]'—— 权威段自己就写着十几个号,全文 grep 会把自己的说明文字一起数进去。

核心机制 ​

一、身份快照:整个模块的脊柱 ​

这是理解 agent 模块的第一把钥匙。后面每一页都会用到它。

AgentExecutionContext(framework/agent/core/gateway/,213 行)做的事一句话: 把 userId / tenantId / loginUser 从 ThreadLocal 里取出来存成普通值,之后在任何线程上都能显式重建—— 不再依赖「当前线程恰好有上下文」。

它的类注释写着「有实测支撑,不是防御性冗余」,而那份实测就固化成了一个可复现的端点 AgentDiagnosticController(324 行)。逐字抄它 :39-47 的结论表:

采样点线程userId / tenantId为什么
controller-entryhttp-nio-exec✅ 拿得到Servlet 线程,直接读 ThreadLocal
flux-sync-emit订阅线程✅ 拿得到同步发射,没换线程
flux-async-emitparallel-N✅ 拿得到Reactor 上下文传播在调度切换时搬运了快照
external-thread-emit请求期间 new 的裸线程✅ 拿得到TTL 继承自 InheritableThreadLocal,创建时继承
prestarted-pool-emit预热线程池❌ 都是 null线程早于请求创建,继承不到;未经 TTL 装饰

这份实测还推翻了一个原本的归因(:49-56 逐字):

原先推断「TenantContextWebFilter 在 finally 里 clear(),而 Servlet 异步下 filter chain 先返回,所以上下文丢失」。实测否定了这条: 外部线程 sleep 800ms(filter 的 finally 早已执行)后依然读得到—— 因为 TTL 是继承语义,子线程持有的是自己那份副本,父线程清理不影响它。

真正的风险点是线程池复用:池化线程在应用启动时就已创建,继承不到任何请求上下文。 HTTP 客户端的 IO 线程正是这种形态。

那既然「大多数情况拿得到」,为什么还要显式快照?:58-68 给了两条理由,都值得逐字读:

  1. Reactor 自动上下文传播——项目里没有任何一行显式配置, 它来自 io.micrometer:context-propagation 被自动装配。 依赖一个没人显式声明过的行为,等于把租户隔离押在框架默认值上。
  2. 上下文是可变且会漂移的——实测中在 parallel-1 上写入的哨兵值, 被原样传播到了 parallel-2。链路上任何一环改了上下文,后续全都跟着变。

这一节的实操结论

不要写「异步链路上应该能拿到租户吧」。要么在 Servlet 线程上调 AgentExecutionContext.snapshot(),要么用 ofSystem(userId, tenantId) 从持久化的执行实例重建, 然后在目标线程上 context.execute(() -> ...)。拿不到身份就拒绝执行,不要降级放行。

二、execute() 为什么不用框架自带的 TenantUtils.execute ​

AgentExecutionContext:155-183 自己管租户上下文,:160-166 的注释逐字解释了为什么:

java
// 🔴 刻意不用 TenantUtils.execute(tenantId, Callable):
// 它在 catch 里把所有异常<b>包装成 RuntimeException</b>(TenantUtils#execute)。
// 那会毁掉两件事:
//   ① ServiceException 被包装后,全局异常处理器认不出来,
//      用户看到的是「系统异常」而不是「您没有执行该操作的权限:…」
//   ② AI 的自修正依赖准确的错误信息,包装后拿到的是一层无意义的外壳
// 所以这里自己管理租户上下文 —— 逻辑与 TenantUtils 一致,只是不吞掉异常类型。

第 ② 条是 agent 特有的:AI 会读错误信息然后自己改。 把 ServiceException 包成 RuntimeException 之后,模型拿到的是一句没有信息量的外壳, 自修正就退化成瞎猜。这是「一个看似无害的框架便利方法,在 AI 场景下变成了缺陷」的典型。

同一个方法里还藏着第二个陷阱,setLoginUserWithoutRequest:185-201:

⚠️ 不能直接用 SecurityFrameworkUtils.setLoginUser(user, null): 它内部走 WebAuthenticationDetailsSource.buildDetails(request), 而后者会读 request.getRemoteAddr()——传 null 直接 NPE。

那个方法隐含假设「设置身份这件事总发生在一次 HTTP 请求里」, 而 agent 的执行恰恰可能落在与请求无关的线程上。

finally 里的还原也有讲究(:171-181):用 SecurityContextHolder.clearContext() 而不是 setLoginUser(null, ...)——后者会构造一个 principal 为 null 的 Authentication 塞进去, 那不是「没有身份」,那是「有一个空身份」。

三、六步网关,以及为什么对话入口只跑四步 ​

AgentSecurityGateway(176 行)是所有工具执行的唯一入口,类注释第一句就是 「任一条不满足就直接拒绝并终止——不降级、不继续」。

步检查失败抛位置
1context 与 userId 非空AGENT_NO_LOGIN_CONTEXTcheckIdentity:98-101
2tenantId 非空AGENT_NO_TENANT_CONTEXT:102-104
3不处于跨租户状态AGENT_CROSS_TENANT_DENIED:113-117
4租户级总开关(查得到配置行)AGENT_DISABLEDcheckEnabled:125-129
5工具存在且已启用AGENT_TOOL_NOT_FOUND / AGENT_TOOL_DISABLEDcheckTool:139-173
6声明的每个权限串都有AGENT_PERMISSION_DENIED:152-156 / :169-173

check(context, toolName) 跑 1~6;checkChat(context) 只跑 1~4。为什么对话也要过闸门, :76-84 逐字:

一次对话未必会调到工具(比如模型直接作答,或者检索那一步失败了), 那样就一个 check 都不会跑——身份是否完整、总开关是否开着, 全都没人验过,而 LLM 已经被调用、token 已经被消耗。

同一个 javadoc 还划清了一条分工,值得单独记住:

⚠️ 这里不校验 agent:assistant:use 权限,那一条由 Controller 的 @PreAuthorize 负责。 刻意不在这里重复:两处都写,将来改权限串时很容易只改一处, 而「改了却还有一处在用旧串」的表现是随机的 403,极难查。 分工是明确的——入口权限归 Controller,工具权限归网关。

第 3 步(跨租户)的理由不是「不方便」而是「校验会失效」(:109-112): SecurityFrameworkUtils.skipPermissionCheck() 在 visitTenantId != tenantId 时返回 true, 功能权限与数据权限会被一起短路。会话若在这个状态下发起,第 6 步等于没做。

第 4 步的口径也值得抄下来(:120-124): 「查不到配置行就是未启用——『默认关着』是数据层面的事实,不靠默认值写对」。

第 5~6 步是 registry-first(:131-137):先查代码工具注册表,命中则走逐字节不变的原路径; 查不到才回落到用户自定义工具目录 agent_tool_def,镜像同一套校验。 「代码工具永远优先——即便有人漏了登记时的重名校验,用户工具也抢不到代码工具的名字」。 权限是 AND 语义(全都有才放行),:149-151 的理由: 「只查第一个的话,用户会卡在链条中间——而中间态往往比没做更糟」。

四、声明值 vs 生效值:全模块最重要的一条纪律 ​

AgentToolRegistry.ToolDefinition(:124-278)把每个工具属性分成两半:

  • 声明值:declaredPermissions / declaredRisk / declaredEnabled / … 全是 private final,来自 @AgentTool 注解,代码即事实,跨租户共享,不可变。
  • 生效值:getPermissions() / getRisk() / isEnabled() / … 每次调用都 override() 现取现算,可被 agent_tool_override 一行数据改写。

于是「读哪一个」成了一个安全决策。实测结论(grep 全 main):

getter非注释调用点分布
ToolDefinition#getRisk()(生效)7 处AgentConfigController:113,114,131、AgentGatewayDiagnosticController:82、AgentToolOverrideServiceImpl:327,328、AgentNameCatalogServiceImpl:128
ToolDefinition#isRequireConfirm()(生效,内部走 getRisk())4 处AgentConfigController:116、AgentGatewayDiagnosticController:84、AgentToolOverrideServiceImpl:331、AgentNameCatalogServiceImpl:132
getDeclaredRisk()(声明)用在判定上7 处AgentChatToolLoop:272、AgentLoopGraphBuilder:215 与 :455(两道闸)、WorkflowChangeManifestBuilder:283、WorkflowDslPlannerPrompt:792、WorkflowDslValidator:5279-5280、AgentDefServiceImpl:426
getDeclaredRisk()(声明)用在展示上5 处AgentConfigController:127、AgentToolExecutor:135、AgentToolOverrideServiceImpl:338 与 :382、AgentNameCatalogServiceImpl:129

11 处生效值调用,全部落在 4 个「给管理员看」的文件里——管理页 tool-list、 设计器名称目录、诊断端点、覆盖维护的合并视图。这与 AgentToolOverrideServiceImpl:61-62 的自陈完全吻合:

生效值 getRisk()/isRequireConfirm() 在 main 里的调用方全是界面 (管理页 tool-list、设计器名称目录、诊断端点、本类的合并视图)。

而7 处判定路径全部读声明值,且形状完全一致——null 一律回落 RiskLevel.HIGH(fail-close):

java
RiskLevel declaredRisk = def.getDeclaredRisk() == null ? RiskLevel.HIGH : def.getDeclaredRisk();

这 7 处手写三元是有名字的理由的:「宁可多说风险」

RiskLevel 上有一个 parseOrHigh(String raw)(RiskLevel.java:108), 但它接的是字符串——服务于自定义工具那条路(agent_tool_def.risk_level 是个字符串列, AgentToolDefServiceImpl:149 用它)。内置工具那条路拿到的是可能为 null 的 RiskLevel, 没有对应的 helper,于是 7 个文件各自手写了同一个三元。

回落方向不是随手挑的,WorkflowChangeManifestBuilder:281-282 把它写成了口径:

declaredRisk 理论上不会为 null(注解必填、自定义那侧走 parseOrHigh)。真为 null 时按 HIGH 兜—— 与 RiskLevel.parseOrHigh 的回落方向一致:宁可多说风险,不能把写操作标成只读。

WorkflowDslValidator:5723-5724(在 ToolResolver#declaredRiskOf 的 javadoc 里)重复了同一句(「认不出的工具按 RiskLevel#HIGH 兜, 与 RiskLevel.parseOrHigh 同方向」)。

口径只有这两份文字,实现有 13 处,方向全部一致(数法:parseOrHigh( 的调用点 6 处 ——WorkflowChangeManifestBuilder:290,546、WorkflowDslPlannerPrompt:891、AgentToolCatalog:237、 AgentToolDefServiceImpl:149、AgentNameCatalogServiceImpl:163;显式 RiskLevel.HIGH 兜底 7 处 ——WorkflowChangeManifestBuilder:283、WorkflowDslPlannerPrompt:854、AgentToolCatalog:224、 WorkflowDslValidator:5661,5666,5728,5737。@AgentTool(risk = RiskLevel.HIGH) 那 4 处是声明不是兜底,不计)。 本页上一版写「七处实现」,差额主要是 476b4b2637 新增的 AgentToolCatalog 一个文件就占了 3 处。

两种「放松」的代价不一样重,别混为一谈

这是 AgentToolOverrideServiceImpl:56-71 的实测口径,也是本页最该记住的一条:

  • 降风险:不改变任何强制行为,只改变界面上的风险标注。 因为全部运行期闸——WorkflowDslValidator 的 V3/V11、WorkflowDslPlannerPrompt 的工具目录、 AgentLoopGraphBuilder 的两道闸、AgentChatToolLoop、AgentDefServiceImpl 的发布判定、 AgentToolExecutor 的沙箱 mock 判定、WorkflowChangeManifestBuilder——读的都是 getDeclaredRisk()。 真正要紧的后果反而是界面误导:设计器把它标成「无需确认」,用户放进 agent_loop, 发布期被 V3 按声明值拒。
  • 删权限:是真放松。AgentSecurityGateway#checkTool 逐个权限 AND 判定时读的是 生效值 getPermissions()(AgentToolRegistry:243-246), 删空之后「调用只需登录」立刻对 AI 生效。

失效方向:降风险 = 界面骗人(拦得住,但人会困惑);删权限 = fail-open(真放行)。

同一个陷阱在两处各写了一遍注释

AgentConfigController:125 与 AgentNameCatalogServiceImpl:119 都写着 「declaredRequireConfirm 也不能图省事直接调 def.isRequireConfirm():那个 getter 内部走 getRisk()」。 两份注释重复不是疏忽——这两个类相隔很远,各自都容易被顺手写错, 而写错的表现是「管理员明明没维护,界面却显示成维护后的值」,很难联想到 getter 选错了。

五、架构测试是这个模块的宪法 ​

AgentArchitectureTest(src/test/java/.../framework/,472 行、8 个 @Test) 用源码文本扫描而不是 ArchUnit 守五条禁令。为什么这么选,类注释 :25-27 逐字:

这几条禁令光靠约定挡不住——它们要防的恰恰是「照着隔壁模块抄」这个最自然的动作。 用源码扫描而不是 ArchUnit,是因为要拦的是字面用法而非类型依赖, 扫文本更直接、也不必为此引入新依赖。

#测试方法禁令
1testNoTenantExecuteIgnore:63-81禁 TenantUtils.executeIgnore——它把租户隔离整个关掉
2testNoTenantIgnoreAnnotation:151-193禁 TenantIgnore(注解形式,与 1 是同一个洞)
3testTenantIgnoreAllowlistIsNotStale:206-230反向自检:白名单那一处必须还在、处数没变、且真的是注解形式
4testNoCustomTablePrefixConstants:232-254禁自定义系统表前缀常量——名单只能有一份
5testOnlyDependOnApiLayer:256-303跨模块只能走 api/ 层
6testScannerActuallyCoversSources:305-315反向自检:扫描确实覆盖到了源码,防止测试空跑
7testDeclaredRiskOnlyOnGatePaths:352-403校验路径与 Planner prompt 装配路径禁 getRisk() / ToolDefinition#isRequireConfirm()
8testDeclaredRiskActuallyUsedOnGatePaths:413-431反向自检:那两条路径确实还在读 getDeclaredRisk()

8 条里有 3 条是反向自检。这不是过度设计,:29-32 把理由写死了:

🔴 每条禁令都配一句「反向自检」。文本扫描有个共通的失效方式: 把要守的代码删掉,禁令就更绿(判定没了,违规自然没了;目录挪走了,Files.walk 什么都不返回)。 所以每条禁令旁边都钉一句「那段东西必须还在 / 那个目录必须扫得到」, 让「静默失效」变成「撞红」。方案 §4.2 管这叫变异探针。

几个值得单独抄下来的细节:

  • 白名单钉「恰好几处」而不是「这个文件豁免」(:85-95)。 全模块只有 1 个文件被豁免:job/AgentUsageLogRetentionJob.java,恰好 2 处 (实测::4 的 import 行 + :49 的注解行;:27 那行是 javadoc,被 isCommentLine 跳过)。 「文件级豁免正是新违规的藏身处——谁在同一个文件里再开一处 @TenantIgnore,禁令照样绿」。
  • 注解形式比 executeIgnore 更宽(:176-180):@Target 含 TYPE 且 @Inherited, 贴在 DO 类上等于把整张表加进 ignoreTables、贴在 Controller 类上等于把整个 URL 加进 ignoreUrls (infra 的 CodegenTableDO / ConfigDO / FileConfigDO 就是这么用的); 它还有一个支持 SpEL 的 enable()。这几种形状字面量扫 executeIgnore 一个都看不见—— 这就是缺陷 E11 的来历。
  • 刻意扫裸 token 而不是 @TenantIgnore(:118-132):只扫带 @ 的会漏掉 import 行与全限定名写法。 代价是标识符名字里含这四个词的也会命中(变异探针那次就撞上了:探针类名叫 MutationProbeTenantIgnore,类声明行被一并报出,预期 2 处实际报了 3 处)。 这里刻意不收窄成正则:「多报一个名字里带 TenantIgnore 的类」的代价是改个名, 而「少报一种没想到的写法」的代价是隔离被静默关掉。命中了但不是注解用法时,改名即可,别加白名单。
  • 表前缀禁令有一段真实事故背书(:243-250): 「这份名单在 S0 之前后端有 5 份副本、三套口径,其中表单设计那 3 份只认 system_/infra_/bpm_—— 于是 sys_grant 能被绑定为可写主表,绕过整个权限判定链」。 正确做法是调 SystemTablePolicy.isAllowed(tableName, TableOperation.XXX) (该类在 enlev-spring-boot-starter-mybatis,不在 agent 模块), 按操作类型取口径:READ 无限制 / WRITE 8 个前缀 / DDL 与 REGISTER 24 个。
  • isCommentLine:467-470 的口径偏严:整行是注释才跳过,行尾注释不跳。 「宁可误报也别漏报」。

架构测试里的行号引用已经漂移(2026-10-25 复核)

这条测试自己在注释里引了四处外部行号,四处都已失效:

引用处注释里写的实测
:264DataRowFilterResolver:144-146:173 的 if (userId == null)(文件 837 行)
:265PermissionFieldHelper:80-87:79-85(loginUserId == null → unrestricted())
:334WorkflowDslValidator:693:952 的 if (declared.isRequireConfirm())(文件 5800 行)
:442WorkflowDslValidator:2773-2774 的跨行三元这个形状已经不存在了:全文件搜不到任何跨行三元,现存两处声明风险级取值都是单行(:951、:3051 的 RiskLevel declared = tools.declaredRiskOf(toolId));被扫的另一个目录 core/plan 里 WorkflowDslPlannerPrompt:854 是单行三元。「连下两行一起看」因此退化成防御性写法,不再对应任何活例子

禁令本身全部有效(扫的是字面量,不依赖行号),失效的只是给人看的出处。 失效方向:静默——它只会让后来人按图索骥时找不到地方,不会让任何一条禁令变松。

🔴 本页上一版把第三行记成「实测 :939」、第四行记成「实测 :5279-5280」,那是当时的真值, 但**「实测值」本身也会随后端提交继续漂**:WorkflowDslValidator 从 5341 行涨到 5800 行, 第四行指向的代码更是直接被重构掉了。所以别把这张表当权威——要引用行号就现查, 或者改成引用方法名 / 常量名(isRequireConfirm 的白名单接收者是 DECLARED_RISK_RECEIVERS, 这个名字比行号稳)。

六、配置面:8 个 @ConfigurationProperties,但前缀不统一 ​

framework/agent/config/ 下 8 个配置类,前缀一律 enlev.agent.*:

类前缀关键默认值
AgentChatPropertiesenlev.agent.chatsuggestionEnabled=true、streamTimeoutSeconds=120、titleMaxLength=20
AgentDocPropertiesenlev.agent.docautoDetectMarker="ainb-ui-admin-vue3/docs"、autoDetectMaxDepth=5、topK=5、similarityThreshold=0.2、splitStrategy="markdown_qa"
AgentIntentPropertiesenlev.agent.intent意图分类
AgentLoopPropertiesenlev.agent.loopagent_loop 节点
AgentPlanPropertiesenlev.agent.plan规划轮数上限
AgentSanitizePropertiesenlev.agent.sanitizechatMaxLength=4000、webhookBodyMaxLength=32768、formEventValueMaxLength=8192、retrievalSegmentMaxLength=4000、toolResultMaxLength=16384、maxDepth=16
AgentUsagePropertiesenlev.agent.usageinputTextRetentionDays=180
AgentWebhookPropertiesenlev.agent.webhookrateLimitEnabled=true、rateLimitIpCount=60、rateLimitIpWindowSeconds=60、rateLimitPathCount=300

两个调度器用的是另一个前缀

job/WorkflowScheduleTriggerScheduler.java:87 与 job/WorkflowWaitResumeScheduler.java:86:

java
@Scheduled(fixedDelayString = "${agent.workflow.schedule-trigger-interval-ms:5000}", ...)
@Scheduled(fixedDelayString = "${agent.workflow.wait-poll-interval-ms:5000}", ...)

前缀是 agent.workflow.*,不是 enlev.agent.*,也没有对应的 @ConfigurationProperties 类 (直接写在 @Scheduled 的 SpEL 里)。

后果是静默的:你在 application.yaml 里配 enlev.agent.workflow.schedule-trigger-interval-ms 不会报错、也不会生效,轮询间隔悄悄留在 5000ms。 失效方向:静默。要调这两个间隔,配 agent.workflow.schedule-trigger-interval-ms 与 agent.workflow.wait-poll-interval-ms。

两处「要不要留总开关」的取舍正好相反,都是刻意的,值得对照着读:

组件有总开关吗理由(逐字摘自类注释)
AgentWebhookProperties.rateLimitEnabled有「限流器依赖 Redis、Redis 故障时现场需要一个不回滚代码就能恢复通行的止血手段」;但「🔴 关掉之后本端点就回到『谁都能打』的状态,这是知情后的取舍,不是无副作用的开关」
AgentSanitizeProperties刻意没有「本组件没有那种故障模式:它是纯字符串处理,不碰任何外部依赖,不会『自己成为故障源』。于是关掉它能换来的只有『控制字符不再被剥、超长内容不再被截』——那不是止血,是把防线拆了」

AgentSanitizeProperties 的按源分档也同样有理由(:10-15): 五个污点源的「合理长度」差着两个数量级——检索片段是被切过块的,几千字符就是异常; webhook body 是机器对机器的整张订单 JSON,几万字符完全正常。给同一个数只有两种结局: 「取小值会把合法的 webhook 业务数据截成半截(功能坏掉,且没人报错), 取大值等于对检索片段不设防(而检索片段恰恰是最容易被塞进 prompt 的那一类)」。

maxDepth=16 那一条的定性最要紧(:95-104):

🔴 这一条不是「精细度」参数,是「别把线程搞死」参数。 webhook body 的嵌套深度由调用方决定,无界递归会在图执行线程里抛 StackOverflowError—— 那不是 Exception,执行层那些 catch (Exception) 一个都拦不住。 ……🔴 超限的处置是丢弃该子树并记 warn,不是「原样保留」—— 原样保留等于让一个没被处置过的值进了 state,那是这道闸唯一不能有的形状。

七、依赖钉版:两处刻意的「不要修正」 ​

pom.xml:27-33 逐字:

xml
<!-- 🔴 与 enlev-module-ai 对齐:spring-ai 锁 1.1.8。
     spring-ai-alibaba-graph-core:1.1.2.2 传递声明的是 spring-ai 1.1.2,
     若不在此用 bom 强制,Maven 仲裁会让 graph-core 分支的 1.1.2 覆盖 ai 模块的 1.1.8
     (实测 dependency:tree -Dverbose:1.1.8 被 "omitted for conflict with 1.1.2"),
     而 ai 模块 + dashscope starter 是按 1.1.8 编译的,降级到 1.1.2 有 NoSuchMethodError 风险。
     P0 spike 已验证 graph-core 1.1.2.2 运行在 spring-ai 1.1.8 上正常。 -->
<spring-ai.version>1.1.8</spring-ai.version>

pom.xml:71-75 逐字(graph-core 为什么锁 1.1.2.2):

🔴 版本锁定 1.1.2.2,与 enlev-module-ai 的 spring-ai-alibaba-starter-dashscope 同版本线: 2.0 线需 Boot 4.0,与本项目 3.5.15 不兼容,只随 1.x 安全补丁升级,不跨 2.0。

GraalJS 是显式声明而不是靠 infra 传递进来的,:82-84 的理由值得抄:

本模块直接 import org.graalvm.polyglot.*,哪天 infra 不再依赖它, 这里会编译失败而不是运行期莫名 ClassNotFound。

这三处都在根 AGENTS.md 里被标了「不要修正」

spring-ai-bom 钉版尤其容易被 IDE 的「依赖优化」提示带走。 删掉它的表现不是编译失败,而是运行期 NoSuchMethodError——离原因很远。

数据模型 ​

16 张 agent_* 表,与 16 个 *DO.java 严格 1:1(实测 @TableName 值集合与 sql/mysql/ainb-init.sql 的 CREATE TABLE 集合完全相同,无孤儿表也无孤儿 DO)。

分组表一句话
对话agent_conversation / agent_message会话与消息,agent_message.status 有 SUCCESS / FAILURE / CANCELLED 三态
智能体agent_def智能体定义(绑工具、绑提示词)
工作流agent_workflow_def / agent_workflow_execution / agent_node_execution定义(含版本)、执行实例、节点 trace
规划agent_planner_adoption草稿被采纳的指标(baseline_dsl 是全仓唯一被冻结的 Plan 副本)
工具agent_tool_def / agent_tool_override / agent_tool_override_audit自定义工具目录、内置工具在线覆盖、覆盖审计
计量agent_usage_log / agent_model_price / agent_quota计费证据、价目、配额
配置agent_config / agent_prompt_template / agent_prompt_template_case租户总开关、提示词模板与样例

ChangeManifest 不在这个列表里,这是刻意的

变更清单(core/manifest/,27 个文件)永不存库。 唯一例外是 hitl.bpm_approval 节点会把它冻结进 Flowable 流程变量 agent_change_manifest。 理由与结构见 Plan 与人工确认。

agent_tool_override 上没有唯一索引,而且加不上

AgentToolOverrideServiceImpl:78-89 逐字:

deleted 是软删标记,「恢复默认」再「重新维护」留下的墓碑会继续占着同一个 (tenant_id, tool_name)(实测本仓 datamodel_create 名下已积 28 行墓碑)。 于是并发保存能落下两行 live,而旧实现用 selectOne 读它—— 多行时永久抛 TooManyResultsException,且 update 与 reset 都走这个查询, 等于「保存」与「恢复默认」两个按钮一起坏,UI 里没有出路。

现在的处置是「读到时自愈」:保留最新一行、软删其余并 log.error。 三处读快照必须与自愈保留的是同一条,否则自愈本身就会改掉工具的生效配置。

接口清单 ​

15 个 @RestController,全在 controller/admin/ 下:

Controller路径前缀说明
assistant/AgentAssistantController/agent/assistant对话、建议、3 条 SSE 流
workflow/AgentWorkflowDefController/agent/workflow-def定义 CRUD、发布、版本、/copy
workflow/AgentWorkflowController/agent/workflow执行、run-stream、resume-stream、执行史、/event
planner/AgentWorkflowPlannerController/agent/planner草稿生成 run-stream、取草稿、取清单、采纳
def/AgentDefController/agent/def智能体定义
tooldef/AgentToolDefController/agent/tool-def自定义工具 + 内置工具在线维护
prompttemplate/AgentPromptTemplateController/agent/prompt-template提示词模板
prompttemplate/AgentPromptTemplateCaseController/agent/prompt-template-case模板样例
config/AgentConfigController/agent/config租户总开关、工具生效值/声明值合并视图
usage/AgentUsageController/agent/usage用量看板与明细
webhook/AgentWebhookController/agent/webhook@PermitAll,模块唯一的匿名面
diagnostic/AgentDiagnosticController/agent/diagnosticSSE 上下文实测证据(本页第一节那张表)
diagnostic/AgentGatewayDiagnosticController/agent/gateway-diagnostic网关判定链逐步骤回放
diagnostic/AgentApiDiagnosticController/agent/api-diagnostic跨模块 api/ 调用面盘点
diagnostic/AgentRagChatDiagnosticController/agent/rag-chat-diagnostic检索 + 对话联调

匿名面只有一处,但它真的是匿名的

POST /admin-api/agent/webhook/{path} 标了 @PermitAll——不需要登录、不需要租户。 它是整个 agent 模块唯一能被未认证请求打到的端点,因此也是限流与验签唯一真正承重的地方。 两道闸的口径见 安全与提示注入。

4 个诊断 Controller 是「把实测证据固化成端点」

735 行诊断代码不参与任何业务链路。它们存在的意义是: 当一个设计前提靠「实测」支撑时,把那次实测做成可复现的端点, 下一个人不必重新搭一遍环境就能验证前提还成不成立。 本页「核心机制 · 一、身份快照」那张 5 行表就是 AgentDiagnosticController 的类注释——它既是文档也是可执行的证据。

怎么扩展 ​

加一张表 ​

  1. sql/mysql/ainb-init.sql 加 CREATE TABLE agent_xxx(不要新建 xpy-*.sql,见「坑与注意」第 9 条)。
  2. dal/dataobject/xxx/AgentXxxDO.java 加 @TableName("agent_xxx")。
  3. dal/mysql/xxx/AgentXxxMapper.java 继承 BaseMapperX。
  4. 🔴 如果这张表要跨租户读,先读 AgentArchitectureTest 的 TENANT_IGNORE_ALLOWED—— 豁免的门槛是「逐行自证 + 只销毁不外泄 + 线程上没有当前租户」三条同时满足, 而且要在白名单里把理由与恰好处数一起写清楚。 「读租户数据再据此行动」的活一律用 TenantUtils.execute(tenantId, ...) 逐租户跑, 照 WorkflowScheduleTriggerScheduler 抄。

加一个 Controller ​

  1. 建在 controller/admin/<域>/,VO 放同级 vo/。
  2. @PreAuthorize("@ss.hasPermission('agent:xxx:yyy')")——入口权限归 Controller, 不要在 AgentSecurityGateway 里重复校验(理由见「核心机制」第三节)。
  3. 权限串要在 sql/mysql/ainb-init.sql 的 system_menu 里登记, 否则授权页上勾不到。
  4. 🔴 如果要返回 LLM 产物,过 OutputModerator。注意 moderateString 刻意不幂等, 同一段文本审两次会叠两个前缀。
  5. 错误码加进 enums/ErrorCodeConstants.java(65 个都在这里,别新建文件)。

加一条架构禁令 ​

  1. 在 AgentArchitectureTest 加一个 @Test,用 findViolations(片段) 或自己写扫描。
  2. 🔴 必须同时加一条反向自检(「那段东西必须还在 / 那个目录必须扫得到」)。 少了它,「把要守的代码删掉,禁令就更绿」这个共通失效方式就会命中你这条。
  3. 失败消息里写清正确做法,不只是「不许这样」。现有 5 条禁令的失败消息都带 「正确做法:……」段落和可照抄的类名——那不是啰嗦,是让撞红的人当场有出路。
  4. 若要开白名单,钉处数,不钉「这个文件豁免」。

加一个配置项 ​

  1. 优先加进现有的 8 个 @ConfigurationProperties 之一(前缀 enlev.agent.*)。
  2. 🔴 不要用 @Scheduled(fixedDelayString = "${...}") 里的裸 SpEL—— 那会变成第 9 个没有配置类、前缀还不统一的口子(现有两个调度器就是这个形态)。
  3. 每个字段都要写 javadoc 说明为什么是这个数。现有配置类的注释密度是全模块最高的, 那不是文风,是因为这些数是运营参数:现场要调的时候,调的人得知道调大了会怎样。

坑与注意 ​

按失效方向排:先是静默 / fail-open 的(更危险),再是 fail-closed 的(至少会报错)。

  1. 🔴 enlev.agent.doc 的文档同步:代码侧的空转已修、配置侧的错配也已修;而它曾经会删库,这条已在代码里堵死。 分五段说:失效方向变了三次,最后一段才是「配置侧也修好了」。

    • 老实现(M1 之前):标记指到 ainb-ui-admin-vue3/docs,靠向上探测找语料根。那个目录压根不存在,resolveRoot 找不到只往 warnings 里加一句话、返回 null,planSync 返回空列表、doSync 不抛异常 ⟹ 一行文档都不灌且界面看不出坏了。失效方向:静默。
    • M1(1b1ac7b1e9)修好过:root-path 变成必填显式配置项,未配直接抛 AGENT_DOC_ROOT_PATH_MISSING、不是目录抛 AGENT_DOC_ROOT_PATH_INVALID;当时 local 配的是 C:/code/xpy-blog/docs、dev/prod 放注释掉的模板(即「不配就报错」),实测灌进 115 篇。失效方向:报错,这是对的。
    • 🔴 c3dccc1a82 refactor(系统):区分专业版和开源版 曾把它改回去(✅ 已修,见第五段):三个 profile 全部变成 root-path: /,并且删掉了那段解释「为什么必填、空转长什么样」的 🔴 注释(git log -L 可复核)。/ 能过 Files.isDirectory 校验(Windows 下解析成盘根),但默认的 directories = ["guide","develop"] 在它下面不存在 ⟹ 每个子目录各记一条「子目录不存在,已跳过」warning、语料扫出 0 篇、接口返回 200。

    而这不只是「灌不进去」:planSync 的 REMOVED 差集是「库里有、这次没扫到」(:182-196),doSync 对 REMOVED 一律 deleteDocumentByName 连带清掉段落与向量(:302-311)。差集的右项一旦因为 root-path 配错而变成空集,左项就会整个变成待删清单——在那个状态下跑一次 doc-sync,会把已经灌好的 115 篇全部删掉,而向量库的写入不在事务里,删掉补不回来。

    • ✅ 4093e19c7d(2026-10-25)把这条路堵死了:doSync 开头加了硬守卫(:238-253),扫到 0 篇即中止整个同步,连知识库都不建,并把「拦下了几篇的删除」写进 DocSyncResult#failures。失效方向从破坏性变成报错——这是对的,因为「同步完成、0 篇」与「跑成功了、只是恰好没文档」在界面上无法区分,本平台头号缺陷就是这个形状。 配套加了 DocSyncPlan#skippedDirectoryCount(结构化计数,不是从 warnings 的自由文本里做字符串匹配——那是给人看的措辞,改一次文案就把守卫改没了)。 🔴 但「只缺一部分子目录」时刻意不拦删除(:255-263,只把「本次扫描不完整」写进 failures):拦了就等于「真的删掉整个目录」永远传播不到库里,僵尸文档那个缺陷会原地复活,而它是慢慢累积的、比误删更难发现。
    • ✅ 配置侧也已修(2026-10-25):三个 profile 的 root-path 改成 ${AGENT_DOC_ROOT_PATH:...}——local 的默认值是本机手册仓库位置,dev/prod 默认留空,没设环境变量就在第一次调用同步接口时抛 AGENT_DOC_ROOT_PATH_MISSING,而不是像 / 那样静默扫出 0 篇。写法与同批整改里的 DRUID_STAT_VIEW_* 一致:默认值站在安全的那一侧,要放开必须显式给环境变量。 base-url 三个 profile 都是 https://doc.enlev.cn,它与前端引用白名单(ainb-ui-admin-vue3/src/utils/url.ts 的 MANUAL_DOC_HOSTS)是同一个事实的两份拷贝——那道白名单刻意不从后端读配置(否则「后端或库被污染时前端不跟着放开」这道防线就没了),所以改一边必须改另一边。

    所以现在的拦阻有两层:代码里的 0 篇硬守卫,加上 doc-sync-plan 会如实显示 removedCount 与那两条 warning。先看 plan 再 sync 仍然要做——它拦得住「误删一部分」,代码只拦得住「全删」。

    directories 默认 guide,develop、exclude-patterns 排掉 develop/04-upstream/**,这两项没被那次重构动过。

    🔴 但「配置修好」不等于「引用链接可用」:线上托管端把 cleanUrls 的回落写成了 SPA 式 /index.html,对任意路径都返回 200 + 同一份首页,实测知识库要引用的 125 条 url 里 122 条落在首页(只有 /guide/、/develop/ 这类目录索引页因命中 $uri/ 而幸免);而且线上产物比语料旧(还有 05-practice,没有 06-practice 与 05-mobile)。原本要三件都到位:改 nginx 回落、重新构建上传 dist/、再跑一次 doc-sync。 ✅ 第三件已于 2026-10-25 做完,且已全绿:库里现在是 125 篇、686 段、686 段全部向量化,doc-sync-verify 8/8 全 OK、allHit: true、最高相似度 0.77,每条都带 documentTitle 与 documentUrl;文档仓 gen-kb-url-map.mjs 对账「语料 125 / 库里 125 / 对上 125」,集合、路径、主机三项全过——存进去的 url 本身是对的。 中间还摔了一跤:第一次灌到一半撞上 qwen3.7-text-embedding 这单个模型的 DashScope 免费额度用尽(403 AllocationQuota.FreeTierOnly,684 段只有 472 段拿到向量),换到 text-embedding-v4 重灌才通。迁移脚本 ainb/sql/mysql/update/2026-10-25-knowledge-embedding-model.sql;换模型的正确顺序与「向量文件按 Java 类名而非模型名命名、所有 TongYi 向量模型共用一个 json」这个坑,详见本页「未证实」节那条已转为实测的记录。 于是只剩前两件服务器侧的事了;它们做完之后引用链接自动可点,不需要再灌一次库。 验证一律用文档仓的 pnpm check:live(比对渲染出的 <h1>,不看状态码——那台机器上什么都是 200)。详见构建与部署。

  2. hash 口径与「向量化失败不自愈」。 老实现把 syncedHashes 放进程内 Map、重启即失效(重启后全部当 ADDED、重花一遍 embedding 钱);M1 改成从库里 content 现算(口径 = sanitize 之后),重启不再重灌。但 hash 只回答「正文要不要重灌」,而 embedding 失败不改变正文 ⟹ 重跑一律 UNCHANGED、整篇跳过,向量化失败的那些段落永远补不上、且所有计数都是绿的。N12 因此加了独立于 hash 的第二判据 listUnembeddedDocumentNames(段落数 ≠ 已向量化数,含段落数为 0 的,0==0 会被「相等」漏掉)→ 新状态 REEMBED(处置同 CHANGED,先删后建)。失效方向:静默缺一块,已修,且 2026-10-25 实测真的命中过这条分支两次:① embedding 额度中途用尽留下 26 篇缺向量的文档(正文一字未改,hash 判据全 UNCHANGED),重跑 planSync 正好判出 reembedCount 26、其余计数全 0、unchangedCount 99(99+26=125);② 换向量模型时把段落全删、只留文档行,planSync 判出 reembedCount 124 + changedCount 1 = 125、removedCount 0 —— 「含段落数为 0」那半条判据也验到了(counts == null 那一支),并且证明清段落不会误触发 REMOVED 差集。N12 这条独立于 hash 的第二判据在真向量库上有效。

  3. 两个调度器的配置前缀是 agent.workflow.*,不是 enlev.agent.*(见「核心机制」第六节)。 失效方向:静默——配错了不报错,间隔悄悄留在 5000ms。

  4. 逐 chunk 审核有一条跨 chunk 的缝隙。AgentAssistantServiceImpl:1317-1321 逐字: 「注入串正好被切在分片边界上时(前一片『忽略以上』、后一片『指令…』), 单看哪一片都不命中模式,直播这一路就漏标;而落库那份走 buildMessage 审核的是完整 answer,会标出来。 ⟹ 用户当场看到的与刷新后看到的可能不一致(多一个前缀)。 要消掉这条缝只能扣住整段不发,那等于放弃流式输出,代价明显更大—— 故这里选『直播尽力标 + 落库权威标』,缝隙写在明处」。 失效方向:静默,且是明知并接受的取舍,不是缺陷。别去「修」它,除非你愿意放弃流式。

  5. 计量写入失败只记 ERROR,不抛。AgentUsageLogServiceImpl:101-107 逐字: 「🔴 计量失败绝不能让业务调用失败:用户问了话、模型也答了, 结果因为写日志出错收到『系统异常』——那是本末倒置。 但也不能静默吞掉:写不进去意味着账单有空洞,所以打 ERROR 让它在监控里可见」。 失效方向:静默漏账(有日志,但要有人看)。

  6. webhook 的 IP 维度限流可被伪造头绕过。AgentWebhookProperties:49-55 逐字: 「🔴 这一层是『挡老实人的误配置』,不是『挡攻击者』:来源 IP 取自 ServletUtils.getClientIP(),而它按 hutool 的默认头序先读 X-Forwarded-For / X-Real-IP 等客户端可控的请求头。在没有可信反向代理兜住这些头的部署里, 直连客户端改一个头就能换掉自己的『IP』。 真正承重的是下面那层 path 维度——这正是两层必须是 AND 而不是 OR 的原因」。 失效方向:fail-open(IP 层可绕,path 层不可绕)。

  7. catch (Exception) 拦不住 StackOverflowError。 无界递归处置嵌套 map/list 会在图执行线程里抛 Error 而不是 Exception, 执行层所有 catch (Exception) 一个都拦不住。唯一的防线是 AgentSanitizeProperties.maxDepth=16(不是精细度参数,是「别把线程搞死」参数)。 失效方向:线程死。调大这个值之前先想清楚默认栈能撑几千层这句话。

  8. 保留期的两处口径不一致。AgentUsageProperties:37 默认 180, 而 AgentUsageLogRetentionJob:22-24 的 javadoc 写「默认 0 表示不清理。 也就是说这个任务挂上去之后,在有人显式配置保留期之前一行都不会动」。 两句都对,但说的不是同一件事:job 读的是每行自己的 retention_days 列 (写入时从配置快照),2026-09-23 之前写入的行确实是 0,之后写入的是 180。 job 的 javadoc 描述的是那个旧世界,已经过时。 失效方向:文档骗人(机制是对的)。

  9. 代码注释里引用的 xpy-*.sql 一个都不存在。实测: 后端 11 处引用 / 8 个文件名,前端 10 处引用 / 8 个文件名, 去重后 12 个文件名,sql/mysql/ 里一个都没有——那里只有 6 个文件 (ai-2026-04-16.sql、ainb-init.sql、bpm-2026-04-18.sql、im-2026-06-20.sql、 quartz.sql、ruoyi-vue-pro.sql),无一以 xpy- 开头。 被引用最多的一个是 xpy-2026-09-27-agent-fine-grained-perms.sql(5 处)。 ⟹ 注释里的结论多数仍然正确(例如 resumeRoute.ts 说的菜单路径 「AI 助手 → 流程编排」= /ai/agent/workflow,我用 ainb-init.sql 的 system_menu 独立验过,是对的), 但出处已经全部失效。失效方向:静默——按图索骥的人会以为文件被删了。 新写的注释不要再引 xpy-*.sql,直接引 ainb-init.sql。

  10. 架构测试的三处外部行号引用已漂移(见「核心机制」第五节的警告框)。 失效方向:静默,禁令本身不受影响。

  11. framework/ 会碰数据库(10 个文件 import dal)。 给它写单测时不能假设无依赖。失效方向:测试写不出来(不是运行期问题)。

  12. listener/ 目录名骗人:只有 1 个文件,模块里有 2 个监听器。 失效方向:静默漏找。

以下 8 条是 fail-closed——踩了会当场报错或当场拒绝,相对好查,但都值得先知道:

  1. 拿不到身份就罢工。AgentExecutionContext.isComplete():140-142 要求 userId、tenantId、loginUser 三者都非空;AgentSecurityGateway.checkIdentity 缺任一个就抛。 这与下游横切能力语义相反(行权限与字段权限在拿不到 userId 时是放行的)。 别指望下游兜底。
  2. 跨租户状态直接拒(AGENT_CROSS_TENANT_DENIED)。 理由是「校验会失效」不是「不方便」:skipPermissionCheck() 会把功能权限与数据权限一起短路。
  3. 查不到 agent_config 行 = 未启用(AGENT_DISABLED)。 「默认关着」是数据层面的事实,不靠默认值写对。
  4. 工具权限是 AND 语义:声明的每个权限串都得有,缺一个就抛 AGENT_PERMISSION_DENIED。
  5. agent_tool_def 登记时不得与代码工具重名(AGENT_TOOL_DEF_KEY_CONFLICT_CODE_TOOL)。 validateForSave:179-183 的理由:「registry-first 下代码工具永远赢, 用户工具即便登记成功也调不到。与其让它『配了不生效且无提示』(平台头号缺陷), 不如登记时就拦下」。
  6. 风险级为 null 一律回落 HIGH(WorkflowDslValidator:5279-5280、 WorkflowDslPlannerPrompt:792、RiskLevel.parseOrHigh)。
  7. AgentWebhookRateLimiter 在 Redis 异常时 fail-close(tryAcquire:126-134)。 理由是一致性:「同一个匿名面上的两道闸,一道 fail-close、一道 fail-open, 等于把整条链路降级到 fail-open 那一道的水平」。 要「Redis 故障时仍通行」是运营决策,走 rate-limit-enabled=false,不走静默降级。
  8. 限流器取不到 IP 时落进 unknown 这一个桶(:60-68), 「不是『跳过限流』的意思……于是这一层退化成一道全局闸。 宁可误伤也不留空档——匿名面上『识别不出来源』恰恰是最该被拦的情形」。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

测试 ​

🔴 这个模块跑不了 DB 测试 ​

enlev-module-agent/src/test/resources/ 下只有一个 dsl-replay/ 目录(2 个 JSON fixture), 没有 application-unit-test.yaml。而基类 BaseDbUnitTest 是这样声明的:

java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = BaseDbUnitTest.Application.class)
@ActiveProfiles("unit-test") // 设置使用 application-unit-test 配置文件
@Sql(scripts = "/sql/clean.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public class BaseDbUnitTest {

三样东西缺一不可:application-unit-test.yaml(H2 数据源)、/sql/create_tables.sql、/sql/clean.sql。 agent 模块一样都没有。

全仓 5 个业务模块里,只有 3 个有这份配置:

模块application-unit-test.yaml
enlev-module-bpm✅
enlev-module-infra✅
enlev-module-system✅
enlev-module-agent❌
enlev-module-ai❌(连 src/test/resources/ 目录都没有)

⚠️ 这张表曾经有第 7 行 enlev-module-im ✅。那个模块已在 12347efcd1 里连目录一起删掉(ls -d enlev-module-* 只剩 5 个),所以「6 个模块 / 4 个有配置」的旧说法两处都过期了。详见后端架构与模块依赖。

⟹ 2,225 个 @Test 绝大多数是纯 Mockito,没有一个碰真库。 要在 agent 模块加 DB 测试,得先补齐这三份资源,别以为加个 @ExtendWith 就行。 :dsl-replay/ 那两个 fixture 是给 DSL 重放测试读的普通文件,与 H2 无关。

ai 模块同样跑不了 DB 测试

这一点在 ai 域开发 里会更严重:那边有 86 个 @Disabled 分布在 32 个文件里(29 个 *Tests.java 占 81 个,全都禁用),真正会跑的只有 10 个 *Test.java。 改 ai 域没有单测网兜底,改 bpm 有(bpm 的 src/test/resources 齐备、194 个 @Test 只有 5 个 @Disabled)。

架构测试怎么跑 ​

AgentArchitectureTest 是纯文本扫描,不依赖 Spring 上下文、不依赖数据库, 但它依赖工作目录:SOURCE_ROOT = Paths.get("src", "main", "java") 是相对路径。

bash
cd ainb && mvn -pl enlev-module-agent test -Dtest=AgentArchitectureTest

必须在 enlev-module-agent 目录(或让 surefire 以它为 basedir)下跑, 否则 Files.walk 什么都不返回。这正是 testScannerActuallyCoversSources:305-315 存在的理由:

java
assertTrue(files.size() >= 2,
        "扫描到的源文件只有 " + files.size() + " 个,工作目录或路径大概率不对,"
                + "这会让上面两条禁令变成永远通过的空测试");
// 反向自检:诊断端点里一定有 "ContextSnapshot" 这个词,扫得到才说明扫描逻辑有效
assertTrue(!findViolations("ContextSnapshot").isEmpty(),
        "扫描逻辑失效:连已知存在的标识符都找不到");

撞红了怎么读

5 条禁令的失败消息都是多行文本块,结构统一: 「为什么禁 → 正确做法(带可照抄的类名)→ 违规位置(文件:行号 原文)」。 先看「正确做法」再看「违规位置」,因为多数违规是「照着隔壁模块抄」抄来的, 而失败消息里那句「照 WorkflowScheduleTriggerScheduler 抄」就是给你的出路。

未证实 ​

读源码读到、可以直接引用的:

  • pom.xml:15-24、:27-33、:71-75、:82-84 的四段注释(模块独立理由、spring-ai 钉版、graph-core 锁版、GraalJS 显式声明)——逐字读过。
  • AgentExecutionContext 全 213 行、AgentSecurityGateway 全 176 行、AgentArchitectureTest 全 472 行、AgentWebhookRateLimiter 全 136 行、AgentSanitizeProperties 全 108 行、AgentDocProperties 全 95 行、AgentDocSyncServiceImpl 全 659 行、AgentDiagnosticController:33-80、AgentToolRegistry:120-279、AgentToolOverrideServiceImpl:1-120、AgentUsageLogServiceImpl:42-116、AgentAssistantServiceImpl:1264-1353——都是逐字读的。 ⚠️ 前五个「全 N 行」2026-10-25 复核未变;AgentDocProperties(86 → 95)与 AgentDocSyncServiceImpl(269 → 619 → 659)都因 1b1ac7b1e9 与 4093e19c7d 长了一截,本页上一版的数字已订正。 后面那几个行号区间是初版阅读时的位置,之后没有逐个复核,引用前请现查。
  • 规模数字(298/61041、161/2225/62840、15 Controller、67 错误码、16 表 ↔ 16 DO、8 个 @ConfigurationProperties、3 job)——全部本次重新数过。
  • 「agent 是严格的叶子模块」——grep -rln "import cn\.enlev\.ainb\.module\.agent" 排除 agent 自身后命中 0; 引用该构件的 pom.xml 只有 3 处(自身、enlev-server、根 reactor)。
  • 🔴 「生效值只用于展示、不用于判定」这条结论需要重新审计,本页上一版的计数已全部作废。 上一版写「.getRisk() 7 处 / ToolDefinition#isRequireConfirm() 4 处 / 生效值调用全在 4 个界面文件 / getDeclaredRisk() 判定路径 7 处 + 展示 5 处」。2026-10-25 按同口径重数(grep -rn '\.getRisk()' --include=*.java 限 src/main/java,再逐条剔注释)得到的是:.getRisk() 5 处、def.isRequireConfirm() 2 处 (AgentGatewayDiagnosticController:84、AgentToolOverrideServiceImpl:331;AgentToolRegistry:289 是 ToolDefinition 自己的实现体,不算调用方)、getDeclaredRisk() 25 处。 更要紧的是**「全在界面文件」这半句已经不成立**:476b4b2637 新增的 framework/agent/core/tool/AgentToolCatalog.java 在 :225 读 def.getRisk()、在 :137 对生效值调 isRequireConfirm(),而它位于 framework/ 而不是界面层; service/workflow/AgentNameCatalogServiceImpl:128 同样读生效值。 这两处是展示用途还是判定用途,本页没有读透,因此不再复述原结论—— 引用「生效值只用于展示」之前,请先自己把上面 5 + 2 处逐个核一遍所在文件的性质。
  • framework/ 有 10 个文件 import dal、listener/ 只有 1 个文件——实测。
  • xpy-*.sql:后端 11 处 / 8 名,前端 10 处 / 8 名,去重 12 名,sql/mysql/ 6 个文件里 0 个匹配——实测。
  • 两个调度器前缀 agent.workflow.*、保留期 180 vs 0 的两处口径——实测。
  • AgentUsageLogRetentionJob 里 TenantIgnore 恰好 2 处非注释命中(:4 import + :49 注解)——实测,与白名单钉的 occurrences=2 吻合。
  • 全仓只有 bpm / infra / system / im 四个模块有 application-unit-test.yaml,agent 与 ai 都没有——实测。
  • RiskLevel.parseOrHigh 接 String(:108)——实测;「宁可多说风险」这句口径在 WorkflowChangeManifestBuilder:281-282 与 WorkflowDslValidator:5723-5724 两处逐字读过。 ⚠️ 上一版把后者记成 :5266,已随文件增长漂移。另外「内置工具那 7 处手写三元」是错的: 实测 == null ? RiskLevel.HIGH : 只有 3 处,且全在 framework/agent/core/ 下 (manifest/WorkflowChangeManifestBuilder:283、plan/WorkflowDslPlannerPrompt:854、 tool/AgentToolCatalog:224),tool/impl/ 的内置工具里一处都没有。

推理得出、未取证的部分:

  • 「WorkflowDslValidator 的 34 条规则各自判什么」——号段口径已经查实 (权威在 :5349-5441,即 V20 validateChatLegsWriteReply 自己的 javadoc;已按它规定的数法复算, 见 Plan 预览确认与 HITL · V 号段)。 号域本身是活的:本页初版写「V1~V33」(早期全文 grep 的产物)、上一版订正成 「V1~V16 + V18~V32,共 31 个号,下一个可用 V33」,本次实测已是 34 个号、下一个可用 V36 ——V33 / V34 / V35 是这两版之间补进去的。 但那 34 条的逐条判据本页没有读(文件 5800 行,只读过其中几处;上一版列的 :690-696、:863、:939、:4903-4993、:5256-5293、:5266 六个位置全部已漂移, 故不再列出,免得按图索骥读到别的规则上)。
  • 「2,225 个 @Test 绝大多数是纯 Mockito」——「绝大多数」是从 「没有 application-unit-test.yaml ⟹ 没有 BaseDbUnitTest 子类」推出来的, 我没有逐个测试文件确认它们的基类。
  • 「删掉 spring-ai-bom 会 NoSuchMethodError」——这是 pom.xml:31 的断言, 我没有真的删掉跑一次。注释说 P0 spike 验过 1.1.8 上正常,但没验过 1.1.2 上会炸。
  • 「AgentDocSyncServiceImpl 灌库会白花 embedding 费用」——这条推断的前提已经不存在了, 本页上一版还把它挂在「未证实」里。当时依据的是「syncedHashes 放进程内 Map ⟹ 重启后全判 ADDED ⟹ 重灌一遍」;1b1ac7b1e9 之后已无此字段,hash 改成每次 ragApi.getDocumentContentHashes(knowledgeId) 从库里现算(AgentDocSyncServiceImpl:157), 重启不再重判 ADDED,机制详见本页「坑与注意」的 M1 / N12 段。 ✅ 2026-10-25 真跑通了(此前三个 profile 的 enlev.agent.doc.root-path 都配成 /, planSync 对 guide / develop 各记一条「子目录不存在,已跳过」后扫出 0 篇(:141-148), 四条分支一次都没走过;配置已改成 ${AGENT_DOC_ROOT_PATH:...},local 默认指到本机手册仓库)。 实测数字与事前用 doc-sync-plan 核对的预期逐字对上:warnings 0 条、扫到 129 篇、 ADDED 14(9 页移动端 + tasks.md + 改名后的 4 页 06-practice)、CHANGED 36、UNCHANGED 75、 REMOVED 4(05-practice 那 4 篇),库里 115 → 125 篇,耗时 5.4 分钟。 事后文档仓 gen-kb-url-map.mjs 对账「语料 125 / 库里 125 / 对上 125」,集合、路径、主机三项全过 —— 存进 ai_knowledge_document.url 的引用地址是对的(换完向量模型重灌后再跑一次,仍是 125/125/125)。 🔴 第一次没灌完,卡在一个代码之外的地方:684 段里只有 472 段拿到了向量, doc-sync-verify 8 条问句全 ERROR(查询侧的 embedding 同样失败,所以召回压根没跑起来, 不是「召回不到」)。原因是 403 AllocationQuota.FreeTierOnly(包成 NonTransientAiException)。 这个失败的形状值得记住:doSync 照样返回 HTTP 200 且 failures: []。 切分与向量化是 @Async 的,失败不会回传给同步结果;唯一的信号是 doc-sync-status 里 embeddedSegmentCount(472) 与 segmentCount(684) 的差额(该字段的 javadoc 写明了这层含义, 但没有任何断言替你盯着它)。别把 200 + failures: [] 当成灌完了。 另外那次 POST /doc-sync 跑了 325 秒,比 undici 默认的 5 分钟 headers 超时只差一点—— 客户端会先挂断(UND_ERR_HEADERS_TIMEOUT)而服务端继续在跑,此时不要重复点击, 改用 doc-sync-status 看进度(doSync 没有并发锁,lastStatus 是 volatile 而非互斥)。 ✅ 已解决,而且不需要给账号充值:那句报错里的「免费额度」是按模型计的,不是按账号计的。 内置知识库被钉在 qwen3.7-text-embedding(ai_model id 65)上,是那一个模型的额度用尽了; 同一把 ai_api_key 下另有 8 个启用中的向量模型(text-embedding-v4、qwen3.7-text-embedding-flash、 qwen3-vl-embedding…)。把知识库换到 text-embedding-v4 即可。 🔴 但换向量模型不是改一行配置就完事:AiModelFactoryImpl:775 的向量文件路径是 {userHome}/vector_store/simple_{embeddingModel.getClass().getSimpleName()}.json —— 用的是 Java 类名而不是模型名,所以所有 TongYi 向量模型共用同一个simple_DashScopeEmbeddingModel.json。直接换模型会把两个维度/语义空间不同的向量混进一个文件, 而且两个 VectorStore 实例各自的 Timer 每分钟都在覆写它(日志里 Overwriting existing vector store file 就是)。正确顺序是: 停后端(止住 Timer)→ 挪走/删掉那个 json → DELETE FROM ai_knowledge_segment WHERE knowledge_id=7 (只删段落、留文档行,正好把每篇判成 REEMBED)→ 改 ai_knowledge 的模型 → 重启 (确保进程里只有一个 VectorStore 实例)→ doc-sync-plan 核对 → doc-sync → 轮询到 embeddedSegmentCount == segmentCount → doc-sync-verify。 ✅ 换完实测:doc-sync-plan 给 reembedCount 124 + changedCount 1 = 125、 removedCount 0(那 1 篇 CHANGED 是本页——同步之后我又改过正文,hash 确实变了); doc-sync 57 秒跑完,125 篇 / 686 段 / 686 段全部向量化,向量文件 11.43 MB; doc-sync-verify 8/8 全 OK、allHit: true、最高相似度 0.77, 且每条都带 documentTitle 与 documentUrl(https://doc.enlev.cn/...)—— 其中「怎么新增一个业务模块(前后端)」命中的正是本轮新写的 任务索引,说明新语料真的进去了。 这条迁移已固化成 ainb/sql/mysql/update/2026-10-25-knowledge-embedding-model.sql (按模型名关联取 id 而非写死 102,因为 ai_model 的自增 id 因环境而异; DELETE 以「知识库当前还挂在旧模型上」为条件,所以可重复执行——实测二次跑 DELETE/UPDATE 均 0 行、686 段完好)。 🔴 为什么非固化不可:全量 dump ainb-professional-init.sql:8163 里 ai_knowledge 那行 写死的还是 65, 'qwen3.7-text-embedding',并且带着 115 条旧 ai_knowledge_document 与 584 条 ai_knowledge_segment(那些 vector_id 指向的是旧向量文件里的向量)。 重新导一次 dump 就会把本节的修复全部退回,而且是退回到最坏的那种状态: 段落行声称已向量化、向量文件里却没有对应向量,doc-sync 会因 hash 未变而判 UNCHANGED 跳过, 于是知识库「看着是满的、召回全空」。 ✅ 4093e19c7d 加的「扫到 0 篇即中止」守卫此前只有两条 AgentDocSyncServiceImplTest 单测覆盖(Mockito 桩,不是真的向量库),现在真实删除路径也实测走过了 (第一次 sync 删掉 05-practice 那 4 篇时,向量文件从 9.7 MB 掉到 7.81 MB)。
  • 「4 个诊断 Controller 不参与任何业务链路」——是从它们的路径前缀与 @Tag 名称推断的, 没有反向查过「有没有别的类注入它们」。
  • 「framework/ 那 10 处 import dal 都是合理的」——我只逐个确认了 AgentSecurityGateway(注入 AgentConfigMapper 查总开关)与 WorkflowDslValidator(查执行史决定拦还是降档)两处的用途, 其余 8 处只确认了 import 存在、没有逐一读它们拿 Mapper 干什么。

相关页面 ​