> 系列:《VeapAI 自然语言操作实战》,本篇为系列总览。
> 文中涉及的表名、字段名、枚举值与接口路径均已对照仓库 `veap-cloud/DB/veap.sql` 与源码核对;代码路径可直接在仓库中定位。
## 一、起因:一次「查个数据要提单」
上个月同事在群里问我:「查一下状态为启用的用户,为什么还要提单排期?」
我打开那套系统看了看。左边是固定菜单,右边是固定查询表单,想加一个「按状态查用户」的入口,要走需求评审、排期、开发、测试、发版。查一次数据的时间成本,比数据本身贵得多。
这种情况在存量信息化系统里很普遍。系统建的时候是按功能点设计的,菜单是给「知道自己要干什么」的人用的。可业务同事的真实需求往往是一句大白话:「帮我看看张三的账号现在是什么状态。」
我们想让这类系统多一种操作方式:用自然语言说需求,系统自己去找表、找接口、找字段,执行完把结果说清楚。
真正动手才发现,卡点不在大模型,而在三处断链:
1. **表语义断了**:模型不知道 `veap_user` 表的 `status` 字段里,1 代表启用还是停用;
2. **接口语义断了**:模型不知道哪个接口是查列表、哪个是改数据;
3. **知识语义断了**:模型不知道用户口中的「启用」,在系统里对应哪个存储值。
三处任何一处缺了,命令都落不到实处。下面这套设计,本质上就是把这三处断链接上,再用一条可视化编排的链路把它们串起来。
平台上这个流程叫「全局语义命令体系」,是项目内部术语。为了对外好理解,本文统称它为**自然语言操作链路**。
先看一眼这条链路的样子——左边是命令文本这一个唯一入口,中间是五段判断,右边三层语义各管一段事实:
![自然语言操作链路全景:三层语义 + 一个编排]
## 二、一条命令要走完的五个阶段
链路里定义了一组稳定的阶段标识(`stageCode`),命令每往前走一步,都会把阶段和摘要写进执行日志与运行上下文。取值集合是九个:
```text
intent | locate | plan | execute | present | guide | none | confirm | canceled
```
前五个是主链阶段,后四个是引导、不可识别、待确认、已取消这几种分支状态。
| 阶段 | stageCode | 谁完成 | 产出 |
| --- | --- | --- | --- |
| 意图识别 | `intent` | 意图识别节点 + 意图校验脚本 | `intentJson`:意图、复杂度、操作类型、匹配状态、路由键 |
| 表定位 | `locate` | 表定位节点 + 定位校验脚本 | `targetTableCode`、`targetTableTitle`、`pkField`、`titleField` |
| 计划 | `plan` | 执行计划节点 + 计划校验与组装脚本 | 过滤条件、排序、条数上限、聚合、分组、接口选择、载荷 |
| 执行 | `execute` | 列表查询节点 或 结构化查询节点 | `resultJson` |
| 呈现 | `present` | 结果呈现节点 | `finalMessage`(Markdown 正文) |
意图识别的取值集合是固定的。
意图五选一(`query | stat | analyze | operate | guide`),复杂度二选一(`simple | complex`)。操作类型四选一(`add | edit | delete | grant`),匹配状态三选一(`certain | ambiguous | none`)。
这些值直接决定后面走哪条分支,所以脚本会逐项强校验,不在集合内就报错。
整条链路的规模是实打实的:**49 个节点、58 条连线**。
用到的节点类型取自一个 19 项的固定集合:
- 流程骨架:`start`、`end`、`condition`、`loop`、`var-merge`
- 模型与接口:`llm`、`http`、`private-http`
- 逻辑与输出:`script`、`reply`
- 文件能力:`file-read`、`file-write`、`file-directory-read`、`file-download`、`pdf-write`
- 数据与交互:`mysql`、`oracle`、`form-dialog`、`list-dialog`
这些类型注册在一处,用了未注册的类型会直接报错。
流程定义存在 `ai_agent_process` 表里,关键字段如下(注释原文来自建表语句):
| 字段 | 类型 | 注释原文 | 我们的用法 |
| --- | --- | --- | --- |
| `process_type` | varchar(32) | 流程类型(唯一取值 agent_flow) | 默认值就是 `agent_flow` |
| `definition_json` | text | 图编排定义 JSON(nodes/edges/definition_info 合并) | 49 个节点与 58 条边都在这里 |
| `start_button_label` | varchar(32) | 启动按钮名称(宽度<=8:ASCII=1/非ASCII=2) | 保存时校验宽度,超了不放行 |
| `allow_edit` / `max_usage` / `used_count` | tinyint/int | 0 时 max_usage 必须 > 0 | 控制使用次数,创建实例时事务内递增 |
表上两个索引 `idx_tenant_service`、`idx_tenant_del` 也说明了设计取向:这个平台是按租户隔离的,所有查询都从租户维度切进去。
## 三、三层语义:不猜,只查
这套链路里有一条贯穿始终的原则:**禁止兜底推断,只做显式声明**。落到实现上,就是三类数据各有一个唯一事实来源。
### 3.1 数据语义:数据表字典
`veap_dict_table` 与 `veap_dict_field` 是表语义的唯一事实来源。前者的关键字段:
| 字段 | 注释原文 | 说明 |
| --- | --- | --- |
| `table_code` | 表名称,默认自动生成 | 表编码,AI 侧一切引用的起点 |
| `title` | 表标题,数据表标题 | 用户口语里的「用户」「角色」靠它对齐 |
| `pk_field` | 主键字段 | 写操作定位记录用 |
| `pid_field` | 父级字段 | 树形结构识别 |
| `state_field` | 状态字段名称 | 状态类命令的落点 |
| `del_field` | 删除字段名称 | 逻辑删除口径 |
| `sort_field` | 排序字段名称 | 「最近创建的」这类命令的落点 |
| `module_code` | 表所属模块,默认 sys 系统模块,通过数据字典配置 | 功能引导时按模块归类 |
| `status` / `del_state` / `sort` | 有效状态 / 删除状态 / 显示次序 | 目录资源只取 `status=1 且 del_state=0`,按 `sort` 排序 |
字段表 `veap_dict_field` 里,AI 侧真正关心的是这些:
| 字段 | 注释原文 | 为什么重要 |
| --- | --- | --- |
| `field_code` | 字段名称 | 计划里的字段引用必须命中它 |
| `db_type` | 数据库类型 | 判断能不能承载非数字值 |
| `is_indexed` | 是否索引 | 排查慢查询时的第一手线索 |
| `parent_id` | 上级字段主键,支持多层嵌套 | 结构化字段的层级 |
| `json_path` | JSON/ARRAY字段路径,如address.city、items[].name或items[0].name | 非平铺字段的取值路径 |
| `schema_json` | JSON/ARRAY字段结构描述(JSON Schema) | 驱动前端动态表单 |
这里有个容易踩的坑,值得先讲清楚:**「这个字段能不能用来查」不在字典表里**。
是否显示为查询项、支持哪种查询方式(等值、模糊、范围),来自在线开发的生成器快照 `veap_generate_field`。
AI 侧拿到的字段清单由端点 `POST /system/veapDictTable/listFieldMetas` 返回。它会左关联最新一次生成记录的字段快照,返回八个键:
- `fieldCode`、`title`、`desp`、`fieldType`:字段基本信息
- `dbType`:数据库类型
- `isQuery`、`queryType`:是否作为查询项、查询方式
- `javaField`:列表接口请求体里的属性名
这个划分是刻意的:字典表管「这字段是什么」,生成器快照管「这字段能怎么用」。两者混在一起,后面就没法回答「为什么这个条件查不出来」。
### 3.2 接口语义:生成的接口清单
模型不能靠接口标题猜用途。我们用的是积木库的全量 Java 扫描结果。
AI 侧拿清单的端点按表编码返回最小集清单:`POST /blockgen/veapBlockFunc/listGeneratedApis`。
每个元素五个键:`id`、`title`、`reqUrl`、`reqType`、`apiType`。
其中 `apiType` 是四个值之一:**新增、编辑、删除、自定义**。「自定义」就是列表查询接口。计划校验脚本按 `apiType` 精确匹配挑接口,比如新增操作必须命中 `apiType=新增` 的那条,禁止按标题文本猜。
匹配口径也能查到:表编码先经驼峰转换得到类名称,再关联 `veap_block_class` 找到对应控制器。这段查询落在 `veap-cloud/veap-modules/veap-blockgen/` 的 `VeapBlockFuncMapper.xml` 里。不是散在代码注释里的口头约定。
### 3.3 知识语义:知识主题与上下文资源
用户说的「启用」「停用」,得翻译成存储值 1 和 0。这类翻译不能靠模型自由发挥,我们把它做成了**离线预构建的静态资源**,一共三类:
| 资源 | 知识主题编码 | 一行正文长什么样 |
| --- | --- | --- |
| 表目录 | `agent.globalCmd.catalog` | `表编码:veap_user` / `表标题:用户` |
| 表关系 | `agent.globalCmd.relations` | 在表目录基础上多出 `主键字段:id` / `标题字段:nick_name` |
| 字典翻译 | `agent.globalCmd.dictMap` | `字典:用户状态(sys_user_status)` / `选项:启用 → 1` |
它们经过一条固定的三步链产出:语义模板任务取数渲染成知识条目,知识主题把条目按 `sort` 合并成上下文资源,最后挂到具体节点上注入提示词。
涉及的表是 `ai_knowledge_topic` 与 `ai_app_context_resource`。后者有个唯一索引值得单独说:
```sql
UNIQUE INDEX `uk_owner_key`(`owner_user_id`, `res_key`)
```
资源键 `res_key` 就等于知识主题的 `code`,配合归属用户构成唯一约束。这意味着同一份资源不会因为多跑一次同步任务而出现两份,重复执行是幂等的。
到这里,三层语义各就各位:**字典管字段语义,生成器管可用能力,知识主题管业务术语**。
## 四、编排本身也是配置出来的
链路不是写死在代码里的,它是一张画布上的图。
打开流程定义页,49 个节点分成几类:
- 负责理解:意图识别、表定位、执行计划、授权对象解析
- 负责校验:意图校验、定位校验、字段归一化、接口归一化、计划校验与组装
- 负责执行:列表查询、结构化查询、写接口
- 负责交互:新增确认、修改确认、删除确认、授权确认
- 负责收尾:结果呈现、分析报告、结果消息
节点是有类型的,类型从一个固定集合里取,注册点在 `NodeRegistry`。用了未注册的类型,执行时直接抛技术性错误,不会静默跳过。
节点上的「入参说明 / 出参说明 / 补充说明」也不是随手写的文案。这套说明有统一契约:标题右侧一个信息提示符,内容固定三节布局,文案集中在十个模块里,渲染落点逐个对应到面板组件上。改一处文案,改的是模块,不是复制粘贴。
写操作这条线最能体现「配置出来」的意思。新增、修改、删除、授权四条子链,都按同一个节奏走。
先按定位条件查目标行,把结果整理成统一的三键行(行主键、行标题、原始 JSON),弹出确认列表让用户看一眼。确认之后才调写接口。
确认弹窗不是摆设。它对应的是一条明确的约束:**写操作必须经人工确认后才执行,取消就是取消**。
## 五、几个较真的取舍
### 5.1 为什么表目录要离线做
最早的做法是运行期拉取:命令进来,先调接口把表目录查出来喂给模型。
跑通之后我们把它拆了。原因很简单:表字典和字典项都是平台级静态事实,一天变不了几次,而每次命令都要为它付一次接口耗时。更重要的是,静态化的内容可以离线评审——哪些表该进目录、字段描述写没写清楚,摊开就能看。
换来的是刷新要跑两步(执行语义模板任务、同步知识主题)。这个代价我们认,也如实写进了使用说明:表字典或字典项变更后需要重跑,运行期只读资源。
不是所有数据都适合静态化。字段清单和接口清单就留在运行期,因为它们的输入是命令里动态解析出来的表编码。这条判断标准后来成了团队内部的一句话:只有「平台级静态事实 + 无脚本校验依赖」的数据才静态化。
### 5.2 一次真实的覆盖事故
有一版链路,两个并行节点各自去拉字段清单和接口清单。拉取本身没问题,问题是两个节点的输出都写进了引擎的固定键 `data`。
引擎的规则是后完成的覆盖先完成的。字段清单先回,接口清单后回,于是字段归一化脚本读到的是接口清单。下一段计划校验报错:「执行计划引用未知字段:status」。那一轮所有查询命令全断,报错信息指向字段,根因却在键名。
修法是把并行段两个节点的输出声明改成专属键:`fieldMetasData` 和 `apiMetasData`,脚本按专属键读取,禁止读 `data`。规则也跟着写进契约:同源并行段的节点不允许共用固定键。
这件事之后我们的判断更明确了:**并行省时间,但并行段的输出契约必须显式声明**。
### 5.3 宁可空结果,也不猜
系统里有一条不那么讨喜的规矩:匹配不到就如实说匹配不到。
命令落不到任何表上,直接给可读提示后中断,不去猜最像的那张表。字段清单为空,判定为「目标表不在数据表目录或未登记字段」,提示去补字典,不进下一步。过滤字段没配查询方式,明确报「生成列表接口不会生成该字段的过滤条件」,而不是静默降级成全表查询。
这条规矩的代价是前期配置要做扎实。好处也很直接:出问题时错误信息指向要修的那一步,而不是让模型编一个看起来合理的答案。
### 5.4 性能这件事,我们只写机制
对外讲性能容易滑向数字游戏。这套链路里能拿出来的都是机制和限额,不是倍数:
- **并发**:引擎每轮调度「所有依赖已满足的节点」,没有依赖关系的链自然并行。字段清单和接口清单就是一段同源并行段。
- **批量**:节点实例的初始化是一次请求批量写入,Mapper 固定 14 列多行 VALUES,主键与返回顺序严格对齐,数量不符直接抛错。
- **缓存**:知识主题检索有三层缓存(主题映射、集合映射、缓存水位),检索结果缓存 10 分钟,查询向量缓存 24 小时。
- **硬顶**:结果行数硬顶 200 行,注入模型用于呈现的数据不超过 50 行,超出就截断并在正文里注明。
- **幂等**:向量对象映射用内容 hash 加嵌入模型 ID 判断要不要重算,内容没变就不重算。
不写「提升多少倍」,是因为我们没做过拿得出手的压测。宁可少写一句,也不要编一个数字。
## 六、想跑起来要几步
代码在 Gitee:`https://gitee.com/mindock/veap`。
环境需要 JDK 17、Maven 3.6+、MySQL 8.x、Redis,以及可选的 Milvus。后端按单体模式打包:
```bash
git clone https://gitee.com/mindock/veap
cd veap-cloud
mvn -B -pl veap-package/veap-monolith -am -DskipTests package
```
前端:
```bash
cd veap-ui
npm install
npm run dev
```
起来之后大致三步:在数据表字典里登记表与字段,在流程定义页打开画布看这张图,然后构建三类语义资源(表目录、表关系、字典翻译)并挂到节点上。
跑通之后,一条命令的结果和结论弹窗长这样:
![一条命令的运行结果与结论弹窗]
源代码里可以直接找到这几处:
- `veap-ui/src/views/ai/agentDesign/`:画布编排器(节点面板、连线与启动参数)
- `veap-ui/src/components/AiAgentEngine/`:执行器(节点处理器、调度与状态集合)
- `veap-cloud/veap-modules/veap-blockgen/`:接口扫描与接口清单查询
- `veap-cloud/veap-modules/veap-ai/`:语义模板、上下文资源与运行数据落库
- `veap-cloud/DB/veap.sql`:全部建表语句(`ai_agent_` 开头的表族)
## 写在最后
这套东西里没有魔法。它做的事很朴素:把表语义、接口语义、业务术语整理成机器可查的清单。
然后用一张图把「理解 → 定位 → 计划 → 执行 → 呈现」串起来,每一步都强校验,写操作都让用户确认一次。
它也有明确的边界:当前一个命令执行一次,不做多轮追问;结构化查询只开放读能力,写操作一律走在线生成的接口,靠既有权限体系兜住;语义资源的刷新是离线的两步,不是实时的。
如果你手上的系统也在被「改个查询要排期」困扰,这套链路值得看一眼画布上那 49 个节点是怎么连的。它的价值不在节点数量,在于每个节点都只干一件事,并且干完就校验一次。觉得哪里绕、哪里可以更省,欢迎到仓库提 issue,我们更想知道它在你的业务里卡在哪一步。