源码
5.1 本章目标
学完这章你能做到:
- 用户用自然语言描述报销/请假,AI 自动解析成结构化表单
- 系统自动检测不合规的费用项目(超标、信息不全等)
- 多个 Agent 扮演不同审批角色(主管、财务、HR、总监),进行真实感的审批对话
- 前端实时展示审批进度和每条对话消息
- 保存申请记录,支持查看历史
本章对应项目功能:模块五 — ERP 报销与请假
5.2 整体设计思路
这个模块要解决两个问题:
问题一:填表太麻烦
传统报销系统:找到报销单,一项一项填,费用类型、每个明细的金额和日期...,大概要5分钟。
我们的方案:说一句话,AI 自动填好。
用户说:"上周去上海出差,高铁来回980,住宿两晚共1100,餐饮三天共420"
AI 解析出:
{
type: 'travel',
items: [
{ name: '高铁票', amount: 980, date: '2024-03-11' },
{ name: '住宿费', amount: 1100, date: '2024-03-11' },
{ name: '餐饮费', amount: 420, date: '2024-03-11' },
],
totalAmount: 2500,
reason: '上海出差'
}
问题二:审批过程不透明
传统审批:提交后等邮件,不知道到哪一步了,也不知道审批人有什么疑问。
我们的方案:模拟真实审批对话,每个审批角色的问题和意见都实时可见。
主管:这次出差的目的是什么?预期产出是?
申请人:参加产品发布会,预期建立合作意向,下周跟进
主管:好的,费用合理,通过
财务:注意住宿费偏高,每晚550元在公司标准范围内,通过
5.3 自然语言解析:Structured Output 的应用
5.3.1 核心原理
把用户口语化的描述,转成程序能处理的结构化数据,关键在于 Zod Schema + withStructuredOutput。
import { z } from 'zod'
// 定义我们期望得到的数据结构
const ExpenseSchema = z.object({
type: z.enum(['travel', 'meal', 'office', 'training', 'other']),
items: z.array(z.object({
name: z.string().describe('费用项目,如"高铁票""住宿费"'),
amount: z.number().describe('金额,单位:元'),
date: z.string().optional().describe('日期,格式 YYYY-MM-DD'),
})),
totalAmount: z.number(),
reason: z.string().describe('报销事由,20字以内'),
warnings: z.array(z.string()).default([]), // AI 自己发现的问题
})
// withStructuredOutput:保证模型的输出符合 Schema
const extractModel = model.withStructuredOutput(ExpenseSchema)
// 调用
const result = await extractModel.invoke([
{ role: 'system', content: '你是报销单填写助手,从用户描述中提取报销信息...' },
{ role: 'user', content: '上周去上海出差,高铁来回980,住宿两晚共1100...' },
])
// result 就是符合 ExpenseSchema 的 JS 对象,不用手动解析 JSON
console.log(result.totalAmount) // 2500
console.log(result.items.length) // 3
5.3.2 Schema 的 describe 很重要
z.string().describe('费用项目,如"高铁票""住宿费"') 这里的 describe 不是给开发者看的注释, 它会被发送给模型,告诉模型这个字段填什么。
// 好的 describe:给模型明确的指引
type: z.enum(['travel', 'meal', 'office']).describe('费用类型'),
name: z.string().describe('费用项目名称,如"高铁票""住宿费""餐费"'),
date: z.string().optional().describe('发生日期,格式 YYYY-MM-DD,如果用户说"上周"则推算具体日期'),
// 差的 describe(太模糊)
type: z.string(), // 没有描述,模型不知道填什么
5.3.3 System Prompt 里的业务规则
光靠 Schema 不够,还要在 System Prompt 里写清楚业务规则:
const systemPrompt = `你是报销单填写助手。
今天是 ${today}。
规则:
1. 如果用户说"上周",根据今天日期推算具体日期
2. 金额务必精确,提到"约""大概"时保留原数字
3. 如果描述中有金额超过单笔3000元的项目,在 warnings 里提示
4. 如果报销事由不明确,在 warnings 里提示需要补充
5. totalAmount 等于所有 items 的 amount 之和`
把今天日期传给模型是关键:模型的训练数据有截止日期,它不知道"今天"是几号。 只有告诉它今天是什么日期,"上周一到周三"这样的相对时间才能正确推算。
5.3.4 合规检查:AI 解析 + 规则引擎双重保障
解析完之后,再过一遍规则引擎检查合规性:
const EXPENSE_RULES = {
travel: {
hotelPerNight: 800, // 酒店每晚上限
maxSingleItem: 5000, // 单笔最大金额
},
meal: { maxSingleItem: 500 },
}
function checkCompliance(form) {
const alerts = []
const rules = EXPENSE_RULES[form.type]
form.items.forEach(item => {
if (item.amount > rules.maxSingleItem) {
alerts.push(`"${item.name}" ¥${item.amount} 超过单笔限额 ¥${rules.maxSingleItem}`)
}
})
return alerts
}
// 合并 AI 发现的问题 + 规则引擎的问题
form.warnings = [...form.warnings, ...checkCompliance(form)]
为什么要双重保障?
- AI 擅长理解自然语言,比如发现"大概600的餐费"可能有问题
- 规则引擎处理精确的数字比较,不受模型随机性影响
- 两者结合,漏网之鱼最少
5.4 Multi-Agent 审批流设计
5.4.1 什么是 Multi-Agent
上一章的 Agent 是一个 Agent 用多个工具。 这章的 Multi-Agent 是多个 Agent 之间相互协作,每个 Agent 扮演不同角色。
单 Agent:一个 AI,一个人格,多个工具
Multi-Agent:多个 AI,多个人格,相互对话
5.4.2 审批流程规划
根据申请内容,动态决定需要哪些审批角色:
function planApprovalFlow(formData, formType) {
const flow = ['manager'] // 主管是必须的
if (formType === 'expense') {
flow.push('finance') // 报销必须过财务
if (formData.totalAmount > 5000) {
flow.push('director') // 大额需要总监审批
}
} else {
// 请假
flow.push('hr') // 请假必须过 HR
if (formData.workdays > 5) {
flow.push('director') // 长假需要总监
}
}
return flow
}
报销 ¥2500 → 走:主管 → 财务(2个角色) 报销 ¥8000 → 走:主管 → 财务 → 总监(3个角色) 请假 3 天 → 走:主管 → HR(2个角色) 请假 8 天 → 走:主管 → HR → 总监(3个角色)
5.4.3 每个角色的人格设定
关键在于 System Prompt,让每个角色有不同的审核视角:
const systems = {
manager: `你是直属主管,正在审核下属的报销申请。
申请内容:${JSON.stringify(formData)}
你的职责:
1. 判断这次报销是否有业务必要性
2. 金额和事由是否合理
3. 可以提问,然后给出批准/驳回/要求补充的意见
4. 语气严肃专业,像真实的主管。不超过80字。`,
finance: `你是财务专员,负责审核报销合规性。
申请内容:${JSON.stringify(formData)}
公司规定:
- 差旅:酒店每晚不超过800元,机票必须经济舱
- 单笔超过3000元需附发票
你的职责:检查是否合规,发现问题要指出。不超过80字。`,
hr: `你是 HR 专员,负责审核请假合规性。
假期规定:
- 年假:入职满1年后享有5天,每多1年增加1天,最多15天
- 事假:每年最多10天,超过3天影响年终绩效
你的职责:核实假期余额和规定。不超过80字。`,
}
注意这几个细节
- 每个角色都把完整的
formData传进去,这样它们能看到申请的具体内容 finance把公司规定写进去,这样它知道800元/晚这个标准- 字数限制("不超过80字")让回复简洁,否则模型可能写很长一段没用的话
5.4.4 对话流程的三个阶段
每个审批角色的对话分三步:
第一步:审批人看申请,给初步意见(可能有问题)
→ [主管]:这次出差的目的是什么?
第二步:如果审批人有问题,申请人回答
→ [申请人]:参加产品发布会,预期建立合作意向
第三步:审批人看到回答后,给最终决定
→ [主管]:理解了,费用合理,批准
代码实现:
async function runApproverTurn(roleId, formData, formType, history, onEvent) {
// 第一步:审批人初步审查
const firstResponse = await model.invoke([
new SystemMessage(getRoleSystem(roleId, formData)),
new HumanMessage('请审核这份申请,如有疑问可以提问'),
...history,
])
onEvent('message', { from: roleId, content: firstResponse.content, type: 'question' })
history.push(new AIMessage(`[${roleName}]:${firstResponse.content}`))
// 判断是否有追问
const hasQuestion = firstResponse.content.includes('?') || firstResponse.content.includes('?')
if (hasQuestion) {
// 第二步:申请人回答
const applicantResponse = await model.invoke([
new SystemMessage(getApplicantSystem(formData)),
...history,
new HumanMessage('审批人提了问题,请以申请人身份回答'),
])
onEvent('message', { from: 'applicant', content: applicantResponse.content, type: 'answer' })
history.push(new AIMessage(`[申请人]:${applicantResponse.content}`))
// 第三步:审批人给最终决定
const decisionResponse = await model.invoke([
new SystemMessage(getRoleSystem(roleId, formData)),
...history,
new HumanMessage('申请人已回答,请给出最终审批意见(批准/驳回)'),
])
onEvent('message', { from: roleId, content: decisionResponse.content, type: 'decision' })
history.push(new AIMessage(`[${roleName}]:${decisionResponse.content}`))
return { approved: isApproved(decisionResponse.content) }
}
// 没有追问,第一个回复就是最终决定
return { approved: isApproved(firstResponse.content) }
}
5.4.5 全局对话历史
所有审批角色共享同一份对话历史,后面的审批人能看到前面的对话:
// 全局历史从一条初始消息开始
const conversationHistory = [
new HumanMessage(`申请人提交了报销申请:\n${JSON.stringify(formData)}`),
]
// 每个审批人对话完,把对话加到历史里
// 这样财务看主管和申请人的对话,HR 看主管、财务和申请人的对话
for (const roleId of approverIds) {
await runApproverTurn(roleId, formData, formType, conversationHistory, onEvent)
// conversationHistory 在每轮里被修改,自动积累
}
这个设计让审批流更真实:财务不会重复问主管已经问过的问题,HR 能看到前面的完整上下文。
5.4.6 驳回即终止
任何一个审批角色驳回,整个流程停止:
for (const roleId of approverIds) {
const { approved } = await runApproverTurn(...)
if (!approved) {
allApproved = false
finalComment = `被${role.name}驳回:...`
break // 跳出循环,不再继续后面的角色
}
}
5.5 判断"是否通过"的技巧
模型的输出是自然语言,不是布尔值。我们用关键词匹配来判断:
function isApproved(text) {
const rejectKeywords = ['驳回', '不批', '拒绝', '不同意', '不予批准', '无法批准']
return !rejectKeywords.some(kw => text.includes(kw))
}
为什么这样判断是合理的?
因为我们在角色的 System Prompt 里要求它"给出批准/驳回的意见",如果它要驳回,一定会用类似"驳回"、"不批准"这样的词。如果没用这些词,几乎可以认为是通过的。
这是一个够用的工程近似方案,不需要完美。
更严格的方案(可选):用结构化输出让模型返回 JSON:
const DecisionSchema = z.object({
approved: z.boolean(),
reason: z.string(),
})
const decisionModel = model.withStructuredOutput(DecisionSchema)
const result = await decisionModel.invoke([...messages])
// result.approved 是精确的布尔值
缺点是会多一次 API 调用,且输出不是自然语言,前端显示需要额外处理。
5.6 服务端实现要点
5.6.1 SSE 推送顺序
审批流的 SSE 事件顺序:
event: start → { appId }
event: plan → { approvers: [{id, name, icon}...], totalSteps: 2 }
event: approver_start → { roleId: 'manager', role: {...} }
event: message → { from: 'manager', content: '...', type: 'question' }
event: message → { from: 'applicant', content: '...', type: 'answer' }
event: message → { from: 'manager', content: '...', type: 'decision' }
event: approver_done → { roleId: 'manager', approved: true }
event: approver_start → { roleId: 'finance', role: {...} }
event: message → { from: 'finance', content: '...', type: 'decision' }
event: approver_done → { roleId: 'finance', approved: true }
event: final → { approved: true, status: 'approved', approvedBy: ['主管', '财务'] }
event: done → {}
5.6.2 申请记录的存储
// 用 Map 存(生产用数据库)
const applications = new Map()
// 提交时创建记录
const application = {
id: `APP${Date.now()}`,
formType,
formData,
status: 'pending', // pending → approved | rejected
messages: [], // 存所有对话消息
createdAt: new Date().toISOString(),
}
applications.set(appId, application)
// 审批过程中同步更新
application.messages.push(messageData)
application.status = result.status
5.7 前端实现要点
5.7.1 SmartFormParser 的两个模式
文件里的 FormField 是一个内联定义的子组件。Vue3 支持在一个 .vue 文件里有多个 <script> 标签:
<!-- 主逻辑:Composition API -->
<script setup>
const erpStore = useErpStore()
// ...
</script>
<!-- 子组件:Options API(也可以用 setup()) -->
<script>
const FormField = {
props: ['label', 'value', 'highlight'],
template: `<div class="form-field-item">...</div>`,
}
export default { components: { FormField } }
</script>
这种写法适合简单的、只在本文件用的小组件,不需要单独建 .vue 文件。
5.7.2 审批步骤的状态变化
approvalSteps 里每个步骤有 4 种状态,视觉上有明显区别:
// 1. pending:等待(灰色)
// 2. running:审核中(蓝色 + 动画)
// 3. approved:通过(绿色 ✓)
// 4. rejected:驳回(红色 ✕)
const step = approvalSteps.value.find(s => s.roleId === data.roleId)
if (step) step.status = data.approved ? 'approved' : 'rejected'
5.7.3 消息气泡的左右布局
审批人消息靠左,申请人消息靠右,通过 is-applicant 类控制:
<div
class="msg-bubble-wrap"
:class="{ 'is-applicant': msg.from === 'applicant' }"
>
<!-- 靠左:avatar 在前,content 在后 -->
<template v-if="msg.from !== 'applicant'">
<div class="msg-avatar">{{ msg.role?.icon }}</div>
<div class="msg-content">...</div>
</template>
<!-- 靠右:content 在前,avatar 在后(视觉上右对齐) -->
<template v-else>
<div class="msg-content right">...</div>
<div class="msg-avatar applicant">👤</div>
</template>
</div>
.msg-bubble-wrap { display: flex; gap: 10px; align-items: flex-start; }
/* 申请人消息:flex 方向反转 → 右对齐 */
.msg-bubble-wrap.is-applicant { flex-direction: row-reverse; }
5.7.4 打字动画
三个点循环弹跳,表示"正在思考":
.typing-dots span {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--color-text-muted);
animation: typing .8s infinite;
}
.typing-dots span:nth-child(2) { animation-delay: .15s; }
.typing-dots span:nth-child(3) { animation-delay: .3s; }
@keyframes typing {
0%,100% { opacity: .3; transform: scale(1); }
50% { opacity: 1; transform: scale(1.2); }
}
5.8 本章作业
✅****基础功能
- 输入"上周去上海出差,高铁800,住宿两晚960,餐费300",能解析成表单
- 表单展示费用明细列表,合计金额正确
- 住宿费如果超标,warnings 里有提示
- 点击"提交审批",能看到审批人逐个出现的步骤面板
- 审批对话一条一条实时出现,区分左右气泡
✅****进阶功能
- 请假模式:输入"下周一到周三请年假,去旅游",解析结果显示工作日天数
- 提交一个大额报销(>5000元),审批流里出现总监角色
- 提交一个长假申请(>5个工作日),审批流里出现总监
- 审批完成后,左侧申请记录里出现这条记录
✅****测试场景
- 输入:
购买了一本书,68元→ 应该只走主管→财务(无总监) - 输入:
出差报销,机票商务舱8000元→ 财务应该提出"机票必须经济舱"的问题 - 输入:
请病假3天,感冒了→ HR 应该提示需要医院证明
5.9 常见问题
Q:解析结果的 totalAmount 和 items 的 amount 之和不一致?
在 System Prompt 里明确说明:totalAmount 等于所有 items 的 amount 之和。 如果还是不一致,可以在代码里强制修正:
result.totalAmount = result.items.reduce((sum, item) => sum + item.amount, 0)
Q:审批人的回复有时候太长,或者格式很奇怪?
在每个角色的 System Prompt 里加字数限制("不超过80字")和格式要求("不要使用 Markdown,直接说话")。
Q:怎么让审批人有时候驳回,不要总是通过?
在角色的 System Prompt 里加明确的驳回条件:
finance: `...
驳回情形:
1. 酒店超过800元/晚且无特殊说明
2. 机票不是经济舱
3. 餐饮超过500元/次
满足以上任一条件时,必须驳回并说明原因。`
Q:多个用户同时提交,审批流会不会混掉?
不会。每次提交都生成唯一的 appId,conversationHistory 是函数内的局部变量,不同请求的历史完全独立。
Q:想把审批结果真的发邮件/飞书通知怎么做?
在 runApprovalFlow 完成后,调用真实的通知 API:
const result = await runApprovalFlow(...)
if (result.approved) {
await sendFeishuMessage(applicant.openId, `你的报销申请已通过,金额 ¥${formData.totalAmount}`)
} else {
await sendFeishuMessage(applicant.openId, `你的报销申请已驳回:${result.comment}`)
}