AI Agent实战:多轮对话生成表单全链路,AI输出的JSON经常解析不出来
本文收录专栏「一个人用AI做工具矩阵」,关于智枢矩阵表单平台的技术深潜系列。
智枢矩阵最核心的功能是什么?不是拖拽排序,不是条件逻辑,而是——你跟AI说一句"帮我做个员工入职登记表",它就真给你生成出来。
这个功能我之前写过一篇概览(见《AI生成表单:从自然语言到JSON Schema,我是怎么做到的》),被AI Agent技术社区收录了。但那篇偏思路,没展开代码。今天这篇是全链路拆解:从对话架构到JSON解析兜底到文件上传,一个人是怎么把这条链路串通的。
一、全链路总览
用户在对话框输入需求,最终拿到一个可编辑、可发布的表单,中间经历了这些:
用户输入需求(自然语言/文件上传)
↓
Step 1: 拼装System Prompt + 对话历史
↓
Step 2: 调用火山方舟doubao-seed模型
↓
Step 3: AI返回JSON Schema(理想情况)
↓
Step 4: 三层JSON解析兜底(现实情况)
↓
Step 5: 前端根据Schema渲染表单预览
↓
Step 6: 用户补充/修改 → 回到Step 1(多轮迭代)
↓
Step 7: 用户确认 → 保存表单
看起来7步,真正难的是Step 3-4——AI的输出永远不如你预期的那样干净。
二、对话架构:sessionStore + 多轮上下文
多轮对话的关键是维护上下文。用户说"加一个手机号字段",AI得知道前面已经生成了哪些字段,才能正确追加。
2.1 前端sessionStore
// stores/session.ts
export const useSessionStore = defineStore('session', () => {
const messages = ref<ChatMessage[]>([]) // 对话历史
const currentForm = ref<FormSchema | null>(null) // 当前生成的表单
// 发送消息
async function sendMessage(content: string, files?: File[]) {
// 添加用户消息到历史
messages.push({ role: 'user', content })
// 调用后端chat接口,传完整对话历史
const response = await api.post('/form/chat', {
messages: messages.value,
files: files?.map(f => f.name) // 文件名传给后端
})
// 解析AI返回
const parsed = parseAIResponse(response.data)
if (parsed.schema) {
currentForm.value = mergeSchema(currentForm.value, parsed.schema)
}
// 添加AI回复到历史
messages.push({ role: 'assistant', content: response.data.reply })
}
// 重置对话
function resetSession() {
messages.value = []
currentForm.value = null
}
return { messages, currentForm, sendMessage, resetSession }
})
关键设计:每次请求把完整对话历史传给后端,后端不维护session状态。这样简单可靠,不需要Redis存session,重启也不丢上下文。
缺点是token消耗随对话轮次增长,但智枢矩阵的典型对话3-5轮就结束了,token压力可控。
2.2 后端路由
// routes/form.js
router.post('/chat', auth, async (req, res) => {
const { messages, files } = req.body
const userId = req.user.id
try {
// 处理文件上传(如果有)
let fileContent = ''
if (files && files.length > 0) {
fileContent = await parseUploadedFiles(files, userId)
}
// 拼装完整消息
const fullMessages = buildMessages(messages, fileContent)
// 调用AI
const aiResponse = await callAI(fullMessages)
// 解析返回
const result = parseResponse(aiResponse)
res.json({
reply: result.reply,
schema: result.schema,
title: result.title
})
} catch (error) {
console.error('[Chat] Error:', error.message)
res.status(500).json({ error: '对话生成失败,请重试' })
}
})
三、System Prompt设计:让AI输出JSON Schema
AI模型不是人,你跟它说"帮我做个表单",它可能给你写一段散文。必须通过System Prompt精确约束输出格式。
3.1 核心Prompt
// services/ai.js
function buildSystemPrompt() {
return `你是智枢矩阵的表单生成助手。用户会描述他们需要的表单,你需要生成对应的JSON Schema。
## 输出格式要求
你必须严格按照以下JSON格式输出,不要输出任何其他内容:
{
"title": "表单标题",
"fields": [
{
"label": "字段标签",
"type": "字段类型",
"placeholder": "输入提示",
"required": true/false,
"options": ["选项1", "选项2"], // 仅选择类字段
"group": "分组名称" // 可选,用于字段分组
}
]
}
## 支持的字段类型
基础类型:text, textarea, number, email, phone, date, time, select, radio, checkbox
高级类型:file, image, url, rating, slider, cascade, section, description, calculation, location
## 规则
1. 每个字段必须有label和type
2. select/radio/checkbox必须有options
3. 合理设置required(核心字段必填,辅助字段选填)
4. 根据表单场景添加placeholder
5. 相关字段用group分组
6. 不要输出JSON以外的任何文字说明
## 示例
用户:"帮我做一个员工入职登记表"
{
"title": "员工入职登记表",
"fields": [
{"label": "姓名", "type": "text", "placeholder": "请输入真实姓名", "required": true, "group": "基本信息"},
{"label": "手机号", "type": "phone", "placeholder": "请输入手机号", "required": true, "group": "基本信息"},
{"label": "入职部门", "type": "select", "required": true, "options": ["技术部", "产品部", "市场部", "人事部"], "group": "入职信息"},
{"label": "期望薪资", "type": "number", "placeholder": "请输入月薪期望(元)", "required": false, "group": "入职信息"}
]
}`
}
这个prompt我迭代了三个版本:
• V1:只说了"输出JSON",AI经常在JSON前后加解释文字("这是你的表单:"、"希望对你有帮助")
• V2:加了"不要输出JSON以外的任何内容",好了一点,但偶尔还是会带尾巴
• V3:加了完整示例,AI按示例格式输出的概率大幅提升
教训:给AI看一个example,比写十条规则都管用。
3.2 多轮对话的Prompt策略
第一轮对话让AI生成完整表单,后续轮次让它在现有基础上修改:
function buildMessages(chatHistory: ChatMessage[], fileContent?: string) {
const systemPrompt = buildSystemPrompt()
const messages = [{ role: 'system', content: systemPrompt }]
// 加入对话历史
for (const msg of chatHistory) {
messages.push({
role: msg.role,
content: msg.content
})
}
// 如果有文件内容,附加到最后一条用户消息
if (fileContent) {
const lastUserMsg = messages.findLastIndex(m => m.role === 'user')
if (lastUserMsg >= 0) {
messages[lastUserMsg].content += `\n\n[上传文件解析内容]\n${fileContent}`
}
}
// 如果已有表单Schema,告诉AI当前状态
const currentSchema = sessionStore.currentForm
if (currentSchema && chatHistory.length > 0) {
messages.push({
role: 'system',
content: `当前表单Schema如下,请在它的基础上修改:\n${JSON.stringify(currentSchema, null, 2)}`
})
}
return messages
}
多轮的关键:把当前Schema塞进system消息,AI就知道"改"而不是"重做"。没有这个,用户说"加个字段",AI可能重新生成一个完全不同的表单。
四、JSON解析兜底:三层防线
这是整篇文章最值钱的部分。
AI模型号称输出JSON,实际上你会遇到:
• 输出完整JSON,前面带"这是您的表单:\n"
• 输出完整JSON,后面带"希望这个表单对您有帮助!"
• 输出不完整JSON,因为max_tokens截断了
• 输出不完整JSON,因为AI自己中途"觉得够了"
• 输出Markdown代码块包裹的JSON(json ... )
• 输出合法JSON,但结构不对(嵌套层级错了、字段名拼错了)
如果只做JSON.parse(),线上至少30%的请求会报错。
4.1 第一层:清理+直接解析
function parseResponse(aiContent: string): ParseResult {
// 第一步:清理Markdown代码块
let cleanContent = aiContent.trim()
// 去掉 ```json ... ``` 包裹
const codeBlockMatch = cleanContent.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/)
if (codeBlockMatch) {
cleanContent = codeBlockMatch[1].trim()
}
// 去掉前后的非JSON文字
const jsonStart = cleanContent.indexOf('{')
const jsonEnd = cleanContent.lastIndexOf('}')
if (jsonStart >= 0 && jsonEnd > jsonStart) {
cleanContent = cleanContent.substring(jsonStart, jsonEnd + 1)
}
// 尝试直接解析
try {
const parsed = JSON.parse(cleanContent)
if (isValidSchema(parsed)) {
return {
success: true,
schema: normalizeSchema(parsed),
title: parsed.title || '未命名表单',
reply: ''
}
}
} catch (e) {
console.warn('[Parse] 第一层解析失败,尝试第二层')
}
// 进入第二层...
}
这一层能解决60%的情况——大部分时候AI输出的是"差不多"的JSON,只要把前后垃圾文字去掉就能解析。
4.2 第二层:正则提取关键字段
// json
{
"title": "活动报名表",
"fields": [
{"label": "姓名", "type": "text", "required": true},
{"label": "手机", "type": "phone", "required": true},
{"label": "参加人数", "type": "number", "placeholder": "请输入参加人数",
最后少了闭合括号。JSON.parse() 必定失败,但人一眼就能看出前两个字段是完整的。
// 第二层:提取title和完整字段对象
function extractPartialSchema(content: string): ParseResult | null {
// 提取title
const titleMatch = content.match(/"title"\s*:\s*"([^"]+)"/)
const title = titleMatch ? titleMatch[1] : '未命名表单'
// 提取完整的字段对象(每个 {...} 作为一个字段)
const fieldPattern = /\{\s*"label"\s*:\s*"([^"]+)"\s*,\s*"type"\s*:\s*"([^"]+)"[^}]*\}/g
const fields: FieldSchema[] = []
let match
while ((match = fieldPattern.exec(content)) !== null) {
const fieldStr = match[0]
try {
// 尝试解析单个字段对象
const field = JSON.parse(fieldStr)
if (field.label && field.type) {
fields.push(normalizeField(field))
}
} catch (e) {
// 单个字段也不完整,跳过
continue
}
}
if (fields.length > 0) {
return {
success: true,
schema: { title, fields },
title,
reply: `已提取到 ${fields.length} 个字段,部分内容可能不完整,你可以让我补充。`
}
}
return null
}
这个正则提取的逻辑是:只收完整的字段对象,不完整的直接丢弃。宁可少一个字段,也不能让整个解析崩掉。
4.3 第三层:放弃解析,原样返回
// 第三层:AI输出完全不可解析,当普通对话返回
function fallbackToChat(content: string): ParseResult {
return {
success: false,
schema: null,
title: '',
reply: content // 原样返回给用户看
}
}
这种情况下AI可能是在回答用户的问题而不是生成表单,比如用户问"这个表单应该有哪些字段",AI可能先给建议再生成。直接把内容当对话返回,下一轮用户说"好的,按这个生成"就能触发生成。
4.4 三层兜底的完整流程
function parseResponse(aiContent: string): ParseResult {
let cleanContent = preprocessContent(aiContent)
// 第一层:清理后直接解析
try {
const parsed = JSON.parse(cleanContent)
if (isValidSchema(parsed)) {
return { success: true, schema: normalizeSchema(parsed), title: parsed.title, reply: '' }
}
} catch (e) {
// 继续第二层
}
// 第二层:正则提取部分字段
const partial = extractPartialSchema(aiContent)
if (partial && partial.schema.fields.length > 0) {
return partial
}
// 第三层:放弃解析
return fallbackToChat(aiContent)
}
线上跑了一个月的数据:第一层成功约65%,第二层成功约25%,第三层兜底约10%。也就是说,没有第二层的话,四分之一的请求会白跑。
五、文件上传解析:PDF/Word/Excel
智枢矩阵支持上传文件作为表单需求的参考。比如上传一份PDF格式的纸质表格,AI解析后生成对应的在线表单。
5.1 文件解析服务
// services/fileParser.js
export async function parseUploadedFiles(filenames: string[], userId: number) {
const results: string[] = []
for (const filename of filenames) {
const ext = path.extname(filename).toLowerCase()
const filePath = path.join(uploadDir, userId.toString(), filename)
try {
switch (ext) {
case '.pdf':
results.push(await parsePDF(filePath))
break
case '.doc':
case '.docx':
results.push(await parseWord(filePath))
break
case '.xls':
case '.xlsx':
results.push(await parseExcel(filePath))
break
default:
results.push(`[不支持的文件格式: ${ext}]`)
}
} catch (error) {
console.error(`[FileParser] 解析 ${filename} 失败:`, error.message)
results.push(`[文件解析失败: ${filename}]`)
}
}
return results.join('\n\n')
}
5.2 PDF解析
用pdf-parse库提取文本:
import pdf from 'pdf-parse'
async function parsePDF(filePath: string): Promise<string> {
const buffer = fs.readFileSync(filePath)
const data = await pdf(buffer)
// 清理多余空白
const text = data.text
.replace(/\s+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
return `[PDF内容]\n${text}`
}
踩过坑:扫描版PDF(图片PDF)pdf-parse提取不出来,返回空字符串。这种情况下只能提示用户手动描述需求,或者用OCR(智枢矩阵有OCR能力,但在对话生成流程中没串联,后续可以加上)。
5.3 Excel解析
Excel是比较容易出效果的——列头天然就是字段标签:
import XLSX from 'xlsx'
async function parseExcel(filePath: string): Promise<string> {
const workbook = XLSX.readFile(filePath)
const sheetName = workbook.SheetNames[0] // 取第一个Sheet
const sheet = workbook.Sheets[sheetName]
const data = XLSX.utils.sheet_to_json(sheet)
// 提取列头作为字段描述
const headers = Object.keys(data[0] || {})
const rows = data.slice(0, 5) // 只取前5行做示例
return `[Excel内容 - Sheet: ${sheetName}]\n列头: ${headers.join(', ')}\n示例数据:\n${JSON.stringify(rows, null, 2)}`
}
Excel上传的效果很好,因为列头就是天然的label,AI几乎不会理解错。
六、前端Schema渲染:20种字段类型的映射
AI生成的是JSON Schema,前端需要把它渲染成真实的表单组件。
6.1 类型映射表
// utils/schemaRender.ts
const FIELD_TYPE_MAP: Record<string, Component> = {
text: 'ElInput',
textarea: 'ElInput',
number: 'ElInputNumber',
email: 'ElInput',
phone: 'ElInput',
date: 'ElDatePicker',
time: 'ElTimePicker',
select: 'ElSelect',
radio: 'ElRadioGroup',
checkbox: 'ElCheckboxGroup',
file: 'ElUpload',
image: 'ElUpload',
url: 'ElInput',
rating: 'ElRate',
slider: 'ElSlider',
cascade: 'ElCascader',
section: 'SectionDivider', // 自定义:分组标题
description: 'DescriptionBlock', // 自定义:说明文字
calculation: 'CalculationField', // 自定义:计算字段
location: 'LocationPicker' // 自定义:位置选择
}
6.2 Schema标准化
AI返回的字段可能不完整——比如缺placeholder、缺required。标准化函数统一补齐:
function normalizeSchema(schema: any): FormSchema {
const title = schema.title || '未命名表单'
const fields = (schema.fields || []).map((field: any, index: number) => ({
id: `field_${Date.now()}_${index}`,
label: field.label || `字段${index + 1}`,
type: FIELD_TYPE_MAP[field.type] ? field.type : 'text', // 未知类型降级为text
placeholder: field.placeholder || `请输入${field.label || '内容'}`,
required: field.required ?? false,
options: field.options || [],
group: field.group || '',
// 默认值补齐
defaultValue: field.defaultValue ?? '',
validation: field.validation || {},
}))
return { title, fields }
}
未知类型降级为text——这条规则救了我好多次。AI偶尔会生成一些不在映射表里的类型(比如color、password),直接崩掉体验很差,降级为text至少能用。
七、AI参数调优
7.1 为什么选doubao-seed
智枢矩阵用的模型是火山方舟的doubao-seed-1-6-251015,不是最贵的,但做JSON Schema生成够用。
试过几个模型对比:
|
模型 |
JSON格式成功率 |
中文理解 |
价格 |
结论 |
|
doubao-pro |
~85% |
优秀 |
贵 |
性价比不高 |
|
doubao-seed |
~80% |
良好 |
便宜 |
✅ 够用 |
|
GLM-4 |
~70% |
优秀 |
中等 |
格式稳定性差 |
|
DeepSeek-V3 |
~75% |
良好 |
便宜 |
偶尔出幻觉 |
doubao-seed的格式稳定性最好,配合三层解析兜底,综合成功率能做到95%+。
7.2 参数配置
// services/ai.js
const AI_CONFIG = {
url: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
model: 'doubao-seed-1-6-251015',
temperature: 0.7, // 不能太低——字段类型需要一定创造性;不能太高——格式会乱
max_tokens: 1200 // 够生成20+字段的表单
}
temperature这个值我调了很久:
• 0.3:输出格式很稳定,但生成的字段太保守,用户说"活动报名表",它只生成姓名和电话
• 0.5:稍微好一点,但字段覆盖还是不够
• 0.7:甜蜜点——格式基本稳定,字段覆盖也够丰富
• 0.9:格式开始不稳定,偶尔输出非JSON
7.3 max_tokens截断问题
max_tokens: 1200对大部分表单够用,但偶尔遇到复杂表单(20+字段),AI输出到一半被截断。这正是第二层解析的用武之地——截断的JSON也能提取出完整字段。
不过更好的方案是根据字段数量动态调整:
function estimateTokens(fieldCount: number): number {
// 粗略估算:每个字段约50 tokens,基础结构约100 tokens
return Math.min(4096, Math.max(1200, fieldCount * 50 + 100))
}
这个优化还没上线,目前1200 tokens配合三层兜底已经够用。
八、一个完整的多轮对话示例
用户:帮我做一个课程评价表
AI返回(第一轮):
{
"title": "课程评价表",
"fields": [
{"label": "课程名称", "type": "text", "required": true, "group": "课程信息"},
{"label": "授课教师", "type": "text", "required": true, "group": "课程信息"},
{"label": "课程内容满意度", "type": "rating", "required": true, "group": "评价"},
{"label": "授课方式满意度", "type": "rating", "required": true, "group": "评价"},
{"label": "是否推荐给他人", "type": "radio", "required": true, "options": ["是", "否", "看情况"], "group": "评价"},
{"label": "改进建议", "type": "textarea", "required": false, "group": "建议"}
]
}
用户:加一个学期选择字段,选项是2025春季、2025秋季、2026春季
AI返回(第二轮):
{
"title": "课程评价表",
"fields": [
{"label": "学期", "type": "select", "required": true, "options": ["2025春季", "2025秋季", "2026春季"], "group": "课程信息"},
{"label": "课程名称", "type": "text", "required": true, "group": "课程信息"},
{"label": "授课教师", "type": "text", "required": true, "group": "课程信息"},
{"label": "课程内容满意度", "type": "rating", "required": true, "group": "评价"},
{"label": "授课方式满意度", "type": "rating", "required": true, "group": "评价"},
{"label": "是否推荐给他人", "type": "radio", "required": true, "options": ["是", "否", "看情况"], "group": "评价"},
{"label": "改进建议", "type": "textarea", "required": false, "group": "建议"}
]
}
注意AI做了两件事:加了新字段 + 把学期排到最前面(因为它是选课的前提条件)。这种"顺便"的智能排列,比简单的追加好得多。
九、踩坑总结
|
坑 |
现象 |
原因 |
解法 |
调试时长 |
|
AI输出带前后文字 |
JSON前后有"这是您的表单" |
Prompt约束不够强 |
三层解析第一层:提取{}之间内容 |
2小时 |
|
JSON被截断 |
后半部分字段丢失 |
max_tokens不够 |
第二层正则提取完整字段对象 |
3小时 |
|
AI输出Markdown代码块 |
json ... 包裹 |
AI习惯性用代码块格式 |
预处理去掉代码块标记 |
0.5小时 |
|
多轮对话重做不修改 |
用户说"加字段",AI重头生成 |
没告诉AI当前Schema |
把当前Schema塞进system消息 |
1小时 |
|
扫描版PDF解析为空 |
上传PDF后AI没有参考内容 |
图片PDF无法提取文字 |
提示用户手动描述,后续加OCR |
1小时 |
|
未知字段类型崩掉 |
AI生成color类型,前端报错 |
类型映射表没有该类型 |
未知类型降级为text |
0.5小时 |
|
temperature太高格式乱 |
AI偶尔输出非JSON |
0.9时创造性过强 |
降到0.7,格式和创造性平衡 |
1小时 |
十、写在最后
"AI对话生成表单"听起来很简单——调个API就完事了。实际上从Prompt设计到JSON解析兜底到多轮上下文维护到文件解析,每一环都有坑。
三层JSON解析兜底是我觉得最值得分享的设计。没有它,线上四分之一的请求会白跑;有了它,AI输出再脏也能捞到有用的字段。这个思路不只适用于表单生成,任何需要AI输出结构化数据的场景都可以用——永远不要信任AI的输出格式,但永远要尽力从它的输出中提取价值。
智枢矩阵是一个开源AI智能表单生成平台,支持AI多轮对话生成表单、智能评分、数据洞察等功能。如果你在做类似的产品,或者对AI Agent + 结构化数据输出感兴趣,欢迎来聊聊。
你做AI应用的时候,怎么处理模型输出的不确定性? 评论区见 👇
⚙️ 相关链接
• 智枢矩阵开源地址:https://gitee.com/zhang-dongtao/zhishu-matrix-open
• 智枢矩阵在线体验:https://www.zhishujuzhen.com
• 上一篇:火山方舟TTS v3接入全记录:7个坑踩完才跑通
关于作者:独立开发者,用 AI 当队友,一个人干一个团队的活。专注 AI + 工具矩阵方向,智枢矩阵作者。
更多推荐

所有评论(0)