摘要:在 AI 智能体(Agent)开发中,表单参数提取最难过的关卡就是处理系统值列表(LOV)。用户习惯说自然语言(如:"同行人张三"、"到深圳机场"),而企业后台系统严格要求传入唯一标识符(如:"工号 WH123456"、"站点ID:1092")。本文总结了一套经过大量实战检验的开发逻辑,通过明确界定大模型与底层代码的职责,完美解决"同名同姓"、"模糊地名"等 LOV 匹配问题,实现极致且友好的用户交互体验。
一、 核心开发法则:AI 提词过滤 + 代码查表验证
做智能体 LOV 验证,千万不要指望大模型自己去"猜"出系统的底层 ID(大模型没有企业数据库的记忆,强行猜一定会产生严重幻觉)。
最稳妥、最高效的开发分工法则只有一条:
AI 智能体原则上只负责"听懂人话",提取出能用于过滤 LOV 的【自然语言关键字】;随后,通过后处理 Python 代码调用后台 API 接口,根据这个关键字去查询、验证和替换数据。
基于后台数据量的大小,这套法则落地为两种开发策略:
二、 情况 1:小数据量 LOV
什么是小数据量LOV?这里有个简单的标准,就是数据量在100行~300行左右。或者是 AI 能准确识别的上下文提示词的前提下。
适用场景:个人出差单关联、特定城市的厂区列表、可用会议室列表等(数据量小,一次性查全毫无压力)。
开发策略:利用"额外上下文"全量喂给 AI,让 AI 自己智能匹配。
体验优点(为什么这是最推荐的做法?) :
这是能实现最极致、最智能化 LOV 选择效果的方案,也是小数据量场景下的首选。我们引入 AI 智能体的初衷,就是为了让系统去迁就人,而不是让人去死记硬背系统的标准名称。
- 痛点对比 :在传统的代码开发中,过滤 LOV 数据高度依赖数据库的模糊查询(如
LIKE '%关键字%')。这种方式死板和脆弱,只要用户稍微打错一个字、用了俗称别名,甚至把词序说反了,底层立刻就会查不到数据。 - AI 的降维打击:当你把几十条选项全量喂给 AI 上下文后,AI 强大的"语义理解"和"容错纠偏"能力就彻底释放了。哪怕用户给的关键字极其模糊、带有错别字,或者完全是用另一种大白话描述,AI 都能结合语境,像一个极其聪明的真人助理一样,自动帮你"意会"并精准选出对应的系统标准值。
核心逻辑 :在调用 AI 解析参数前,Python 脚本先偷偷调 API 把数据查好,拼成一段文本放进 AI 的提示词上下文(extra_context)中。让 AI 根据用户的语义(如时间跨度、楼层距离)自己去比对挑选。
代码实战(以订票自动关联出差单为例):
1. 开发LOV验证的后台API接口。
因为LOV是需要实时从系统后台获取数据再验证的,API取数的接口开发自然是第一步。
--TEMPLATE:根据工号查询出差单
SELECT M.FD_ID ID, -- '单据ID'
M.DOC_SUBJECT SUBJECT, -- '主题'
M.FD_NUMBER FD_NUMBER, -- '编号'
TO_CHAR(O.FD_KAISHIRIQI, 'YYYY-MM-DD') START_DATE, -- '出差开始日期'
TO_CHAR(O.FD_JIESHURIQI, 'YYYY-MM-DD') END_DATE, -- '出差结束日期'
O.FD_TIANSHU DAYS, -- '出差天数'
TO_CHAR(M.DOC_CREATE_TIME, 'YYYY-MM-DD HH24:MI:SS') CREATOR_TIME -- '创建时间'
FROM OAUSER.KM_REVIEW_MAIN M
LEFT JOIN OAUSER.EKP_XYG_OVER O ON O.FD_ID = M.FD_ID
LEFT JOIN OAUSER.SYS_ORG_ELEMENT E ON E.FD_ID = M.DOC_CREATOR_ID
WHERE M.DOC_STATUS = 30
AND M.FD_TEMPLATE_ID = 'XXXXXXX'
AND M.DOC_CREATE_TIME >= SYSDATE - 365
AND E.FD_NO = '{{emp_no}}'
ORDER BY M.DOC_CREATE_TIME DESC;
备注:写好SQL之后,至于怎么定义到vanna的TEMPLATE模板知识库里面,然后,API怎么用,这里就不多说了。自行查看相关的vanna的文档说明即可。当然,如果是已有的API接口,也可以直接用,不一定非得走vanna的SQL模板API。
2. 参数收集工具的额外上下文代码:
python
def get_extra_context(emp_no: str) -> str:
# 1. 查后台:获取该员工最近1年的有效出差单
trips_data = fetch_api("根据工号查询出差单", {"emp_no": emp_no})
trips_lines = []
for idx, row in enumerate(trips_data):
# 组装关键的判断条件(单号、主题、日期区间)喂给AI
t_range = f" (区间:{row.get('START_DATE')}~{row.get('END_DATE')}, {row.get('DAYS')}天)"
trips_lines.append(f"[{idx+1}] {row.get('FD_NUMBER')} {row.get('SUBJECT')}{t_range} (ID:{row.get('ID')})")
# 2. 组装强指令上下文:逼迫 AI 根据出发日期自动匹配
extra_context = f"""
=== 您的可用出差单列表 ===
{chr(10).join(trips_lines)}
=== 🚨 订票业务匹配指令 🚨 ===
- 🎯 【日期智能匹配】:如果用户指明了订票的出发日期(如"明天"、"5月18日"),你必须进行区间推算!如果订票日期落入上述某个出差单的区间内,你必须自动锁定该出差单并提取其ID!
- 必须先输出 `_think` 打草稿,例如:`"_think": "匹配最近出差单:第[1]条符合用户说的'今天',落在5.07起28天内。"`
"""
return extra_context
3. 实现效果
效果:用户只要说了"明天出发",AI 就能自己做数学题,算出哪张出差单刚好覆盖明天,自动填入关联 ID,实现"免人工选择"的丝滑体验。
💡 订票申请流程办理提示
📋 目前已提取的信息
- 出差单 :YGCC-20260506023 XXX自2026-05-07起出差28天 >>> (备注:这个出差单是AI自己给我选择的)
- 路线:深圳(宝安机场) ➔ 芜湖(宣州机场)
- 去程日期 :2026-05-21 (类型:单程)
- 订票人:某某某(XXXXXXX)
三、 情况 2:大数据量 LOV
适用场景:全球机场站点(超 2000 个)、全公司员工库(数万人)、海量物料库等。
开发策略:AI 提取关键字 -> Python 查接口 -> 0/1/N 状态机处理。
这时候需要注意:绝对不能把几千几万条数据塞给 AI,这不仅会引发 Token 超限和响应超时,而且信息量过大时 AI 也会"眼花"选错。因此,在海量数据面前,必须严格贯彻**"AI 提词 + Python 代码查表验证"**的协同逻辑。
体验优化(缓解自然语言与系统字典的鸿沟) :
既然我们把查表验证的动作交回给了 Python,那就必然会面临传统代码查询最头疼的问题:如果用户使用了口语化的简称或错用了字词,导致 LIKE 查询找不到数据怎么办?
大模型因为没有全量数据做上下文比对,无法自行纠正,只能乖乖提取出用户的原话。对此,其实是没一个完美的解决方案的。目前能缓解这个问题的最高效、最稳健的解决方案是:在 Python 后处理代码中建立一个"高频别名/简称映射字典"(Alias Mapping)。
-
痛点案例 :在订票申请中,用户经常说"去芜宣机场"或"去潮汕"。AI 提取出的关键字是"芜宣"。但底层的系统站点标准名称叫做"芜湖-宣州机场",直接用
%芜宣%去查数据库,结果必定是 0 行,导致流程卡死。 -
优化方案 :在 Python 调接口查询前,先让提取出来的关键字过一遍我们预设的
STATION_ALIAS_MAPPING。如果命中了俗称"芜宣",代码会在底层强制将其转换为对数据库极其友好的%芜湖%宣%。这种"后脑拦截纠错"的方式零 Token 消耗、100% 绝对精确,且后期维护扩展成本极低。只是需要枚举出一些常见的高频的关键字来做转换。# 🌟 体验优化:别名映射字典 (拦截高频口语词汇,优化底层SQL查询命中率) STATION_ALIAS_MAPPING = { "芜宣": "芜湖%宣", "芜宣机场": "芜湖%宣", "潮汕": "揭阳%潮汕", "潮汕机场": "揭阳%潮汕" }
1. 参数模板设计:切分"模糊键"与"精准键"
针对员工这种复杂实体,必须在 JSON 模板中准备两个字段,一个接纳大白话,一个接纳精确代码。因为也可能会直接说工号。
json
{
"empUserNo": {
"type": "array",
"prompt": "提取明确提及的内部人员【工号】,工号格式是2个字母加6个数字,例如`WH230482`。若无工号,默认把当前用户自己的工号作为第1个元素!"
},
"empUserName": {
"type": "array",
"prompt": "提取用户提及的内部人员【姓名】。注意:如果该人员已提取了工号到 empUserNo 中,绝对不要再放到这里!"
}
}
2. Python 核心代码:0/1/N 状态机与多轮提示
Python 拿到 empUserName 中的姓名关键字后,调用 API 接口查询,严格执行三条铁律:
-
命中 1 行:【静默替换】直接拿到后台需要的工号,替换掉 JSON 里的工号字段,直接放行。
-
命中 0 行:【查无此数据】Python 组装友好的报错文本。
-
命中 N 行(同名同姓):【互动澄清】这是最考验交互体验的地方。绝不能直接报系统错,而是要把查询结果的前 5 项列出来,变成一道选择题抛给用户。
3. 防死循环熔断机制
在处理如"内部用车人"这类允许传入多个实体的 数组型 LOV 时,出现同名同姓,多轮对话极易触发一个体验灾难:无尽报错死循环。
【故障复现】:
- 第一轮:AI 从用户大白话中提取了姓名数组
empUserName: ["张三"]。代码查表发现有 5 个张三,报错并把前 5 个人的详情(含工号)列成单选题抛给用户。 - 第二轮:用户回复"选第 3 个"。AI 前脑非常聪明地领会了意图,将这名员工对应的精确工号提取到了工号数组中:
empUserNo: ["WH123"]。 - 陷阱出现 :大模型通常倾向于保留已有的信息,它在第二轮提取时大概率不会清空
empUserName里的"张三"。 - 死循环发生 :此时数据流转到后处理代码。如果代码无脑继续遍历
empUserName里的名字去查库,它会再次查出那 5 个张三,然后再次触发多选项报错阻断流程------用户将被永久卡在这里。
【解决方案:交集比对熔断器】 :
在代码即将针对"张三"抛出多重选项报错前,必须先做一次交集检查 :拿着数据库查出来的 5 个张三的工号集合,去跟用户已经确认好的精确工号数组 empUserNo 取交集。
如果存在交集,说明用户在这一轮(或上一轮)对话中,已经用精确的工号回答过这个重名问题了!此时必须触发熔断:静默丢弃"张三"这个模糊名字,阻止报错逻辑执行。
【特别说明:单值字段无需如此复杂】 :
需要强调的是,上述的熔断机制仅针对 empUserNo 和 empUserName 都是数组(Array)格式的情况 。
在 90% 的常见业务场景中(例如:单一的申请人、单一的项目负责人),工号和姓名通常只是单一的字符串(String)字段。面对单值字段,处理逻辑极其简单粗暴:
优先级法则 :代码在验证前,先检查是否有精确代码(如工号)。"只要有精确代码传入,直接无视并丢弃模糊姓名,绝不再做姓名验证"。这样天然就不会产生死循环,无需编写任何交集熔断逻辑。
4. 完整代码实战(以内部用车人解析为例)
python
# raw_emp_user_name: AI根据用户大白话提取出来的中文名,如 ["张三"]
# final_emp_user_no_set: 当前已确认的准确工号集合
remaining_names_for_next_turn = []
validation_errors = []
for uname in raw_emp_user_name:
uname = str(uname).strip()
# 1. 代码调用后台 API 模糊搜索姓名关键字
e_list = fetch_api(template_key="根据工号查询员工信息", params={"emp_name": uname})
if len(e_list) == 0:
# 【状态 0】:查无此人
validation_errors.append(f"👤 系统未查到匹配【{uname}】的员工,请确认姓名是否正确。")
elif len(e_list) == 1:
# 【状态 1】:精确唯一
# 静默转为工号回填,加入最终集合。下一次对话LLM就不会再丢失它
matched_no = e_list[0]['EMP_NO']
final_emp_user_no_set.add(matched_no)
else:
# 【状态 N】:同名同姓处理
# 🚨 核心:死循环熔断机制(Idempotency 保障)
possible_nos = {e['EMP_NO'] for e in e_list}
intersection = final_emp_user_no_set.intersection(possible_nos)
if intersection:
# 命中熔断:说明用户已经在这一轮/上一轮用明确的工号确认了是谁
# 静默丢弃这个模糊名字,绝对不再重复抛出多选报错!
pass
else:
# 确实没指定,抛出多重选项供用户选择(动态排版,带上部门和岗位以供区分)
opts_lines = [
f"> - [{i+1}] {r['EMP_NAME']} ({r['EMP_NO']}) - {r.get('DEPT_NAME', '无部门')}"
for i, r in enumerate(e_list[:5])
]
opts = "\n".join(opts_lines)
validation_errors.append(
f"👤 系统查询到名为【{uname}】的员工有多位:\n{opts}\n👉 请明确具体的工号,例如:`增加用车人 WH123456`"
)
# 把未解决的名字保留下来,等用户下一轮解答
remaining_names_for_next_turn.append(uname)
# 最终回填干净的数据给下一轮大模型
data_dict['empUserNo'] = list(final_emp_user_no_set)
data_dict['empUserName'] = remaining_names_for_next_turn
注意:所有的这种template_key="根据工号查询员工信息",都是需要自己定义SQL到vanna然后取数的。这里不再啰嗦说明。
5. 实现效果
当用户说"同行人张三",AI 提取出 ["张三"]。代码执行后,返回给用户完美的引导:
👤 【用车人】存在同名同姓
系统查询到名为【张三】的员工有多位:
1\] 张三 (WH518604) - 芜湖设备部
👉 请明确具体的工号,例如:
用车人是WH123456。
用户只需自然地回复"开发部张三"或"第2个",系统即可提取工号,触发熔断器,完美闭环!
四、 疑问:为什么不用通用静态配置(lovConfig)?
敏锐的开发可能会注意到,这里不是有个参数模板定义么?是否可以通过参数模板定义来实现公共的LOV的效果?
--> 结论是,肯定可以啊,只是有点死古板,没那么灵活,而且用户交互体验也没那么好。
这个开发的逻辑是,在参数 JSON 模板里定义一套静态的 lovConfig(包含固定的 API 地址、请求体映射)交给统一的框架去跑。
出差参数的关联项目是用这个逻辑开发的,可以参考:
"projectName": { "label": {"zh_CN": "关联项目", "en_US": "Related Project", "id_ID": "Proyek Terkait"}, "type": "lov", "required": false, "description": {"zh_CN": "请提供出差关联的项目名称", "en_US": "Please provide the related project name", "id_ID": "Harap berikan nama proyek terkait"},
"prompt": "属于lov字段。提取用户描述中的项目核心关键字,例如用户说'跟进XXX管理系统项目的开发',只提取'XXXX生产计划'。注意要保留原语种",
"lovConfig": {
"apiUrl": "https://api.yourcompny.com:8001/xag_vanna/api/runTemplate",
"method": "POST",
"payload": {"mcp_code": "OA_XAG_COPILOT", "template_key": "根据工号查询关联项目", "data_format": "json","params": { "emp_no": "{emp_no}", "project_name": "{value}"}}
}
}
注意,上面的emp_no是约定的,系统会替换为当前的用户工号,而value也是约定的,就是当前AI识别出来的栏位(关联项目)的值。
对应的「参数收集工具」的开发逻辑是:
# 5. 判断结果行数 (结合 required 属性处理)
if not records or len(records) == 0:
if is_required:
raise ValueError(self.t['lov_no_match'].format(val=val, label=label))
else:
return {"valid": True, "normalized": ""} # 非必填,忽略并置空
elif len(records) == 1:
# 精确匹配,返回大写的 CODE
rec = records[0]
code_val = rec.get("CODE") or rec.get("code") or rec.get("NAME") or rec.get("name") or val
return {"valid": True, "normalized": code_val}
else:
if is_required:
# 找到多行,返回给用户选择 (取前5个避免刷屏)
options_str = "\n".join([
f"- {item.get('NAME', item.get('name', ''))} ({item.get('DESCRIPTION', item.get('description', ''))})"
for item in records[:5]
])
raise ValueError(self.t['lov_multi_match'].format(val=val, label=label, options_str=options_str))
else:
return {"valid": True, "normalized": ""} # 非必填,忽略并置空
实战证明,除非是非常简单的独立下拉框,稍微复杂的 LOV 强烈建议写定制化的 Python 后处理脚本!
静态通用配置(lovConfig)主要存在三大致命缺陷:
- 无法做到千人千面的优雅排版 :当出现 5 个同名的人时,我们需要把"部门"、"岗位"甚至是别的信息组合展示给用户看。静态配置只能死板地映射
NAME和DESCRIPTION(因为是公共的配置,所以目前的做法是需要默认约定API接口返回的字段,必须是code,name,description),所以,更多的信息不好展示,用户可能无法区分是哪个张三。 - 无法处理复杂的数组运算:像上面演示的"内部用车人",需要把姓名查出的新工号,和原本已有的工号数组做集合合并、查重和交集熔断,这种复杂的集合运算,静态配置 JSON 绝对做不到。
- 无法做级联与跨字段推导:很多业务场景下,如果用户填了 A 字段,B 字段的 LOV 就必须根据 A 来过滤(比如根据出发城市过滤可用车队)。写 Python 代码可以随意读取全局数据字典,而写死在 JSON 里的配置完全没有这种灵活性。
- 用通用配置目的主要是为了减少开发的代码量。其实现在有AI写代码了,这个已经完全不是问题了。而且每一个流程本来就是需要定制一套参数解析(后处理)代码的,代码负责产生国际化(多语言)的用户交互提示,以及生成调用业务系统的API的参数等等动作。例如
代码执行-解析订票参数。
五、 开发经验总结
看似只是一个简单的 LOV 值列表验证功能,但要真正在企业级应用中完美落地,背后的工程化技巧与体验打磨可谓至关重要。
我认为,处理 AI 智能体表单中的 LOV 问题,本质上是重新界定人机交互的边界 :把"听懂各种大白话和简称"的脏活累活交给 AI 大模型;而把"查数据库、查重、组装完美提示语引导用户"的严谨工作交给 Python 代码,各司其职。
这套经过实战验证的**"AI 提词 + 代码查证 + 0/1/N 状态机路由"组合拳,不仅解决了重名、同名和系统识别死角的痛点,更能在保障系统严谨和稳定的前提下,为您搞定企业内部 90% 以上的复杂参数提取场景,交付真正能落地的智能交互体验**。