结构化信息采集
Agent 发现缺失信息时,不只用文本提问,而是发出结构化采集事件,前端渲染按钮/表单/滑块,用户点击后写入结构化状态。比纯文本问答稳定得多。
#type / concept
#status / evergreen
#tech / ai
#tech / dev / frontend
[!info] related notes
- 所属 MOC: Context Engineering MOC
- 核心概念: Context Engineering
- 状态管理: 问诊状态 Schema
- SSE 协议: LLM Streaming 协议设计
- 前端消费: 前端 SSE 消费
- UI 设计: AI 聊天 UI 设计
结构化信息采集
Agent 发现缺失信息时,不只用文本提问(“是否疼痛?”),而是发出结构化采集事件(ask_user_options),前端渲染按钮/表单/滑块,用户点击后写入结构化状态。这比纯文本问答稳定得多,因为:用户输入有明确语义、不需要 LLM 解析自然语言、直接写入 consultation_state。
传统做法 vs 结构化采集
传统:纯文本问答
AI:你的膝盖下坠感是否伴随疼痛?
用户:没有
AI:(需要从"没有"推断出 pain=none,可能推断错误)
问题:
- “没有”指的是没有疼痛,还是没有肿胀,还是两个都没有?
- 用户可能说”不疼""没有疼痛""还好""不太疼”,模型每次都要解析
- 解析结果不确定,可能出错
结构化:选项式采集
AI:你的膝盖下坠感是否伴随疼痛?
[没有疼痛] [轻微疼痛] [中等疼痛] [明显疼痛] [我不确定]
用户:(点击"轻微疼痛")
→ 系统直接写入 {"pain_level": "mild", "source": "user_option_click"}
优势:
- 用户输入有明确语义,不需要 LLM 解析
- 直接写入 consultation_state,100% 准确
- 前端渲染一致,用户体验好
设计模式
1. Agent 发出采集事件
当 Agent 检测到 missing_slots 不为空时,发出 ask_user_options SSE 事件:
{
"type": "ask_user_options",
"payload": {
"question": "你的膝盖下坠感是否伴随疼痛?",
"field": "pain_level",
"options": [
{"label": "没有疼痛", "value": "none", "icon": "✅"},
{"label": "轻微疼痛", "value": "mild", "icon": "😐"},
{"label": "中等疼痛", "value": "moderate", "icon": "😣"},
{"label": "明显疼痛", "value": "severe", "icon": "😰"}
],
"allow_free_text": true,
"free_text_placeholder": "请描述你的疼痛程度..."
}
}
2. 前端渲染
前端根据事件类型渲染对应的 UI 组件:
| payload 类型 | 渲染方式 |
|---|---|
| options(2~5 个) | 按钮组 |
| options(6+ 个) | 下拉选择 |
| scale(1~10) | 滑块 |
| boolean | 是/否按钮 |
| free_text_only | 输入框 |
3. 用户交互
用户可以:
- 点击选项 — 直接发送结构化答案
- 输入自由文本 — 同时记录结构化标记和原始文本
- 跳过 — 标记为 “user_skipped”,不写入状态
4. 结构化答案回传
用户点击后,前端发送:
{
"type": "structured_answer",
"field": "pain_level",
"value": "mild",
"display_text": "轻微疼痛",
"source": "user_option_click",
"turn_id": "turn_0197..."
}
5. 状态写入
后端收到结构化答案后:
- 直接写入
consultation_state.pain_level = "mild" - 更新
missing_slots(移除 “pain_level”) - 更新
confidence.pain_level = 1.0(用户直接选择,置信度最高) - 作为用户消息保存到 messages(可选,保留对话完整性)
SSE 事件协议扩展
在现有 SSE 事件类型基础上扩展:
| 事件类型 | 说明 | 新增 |
|---|---|---|
| text_delta | 文本增量 | - |
| message_done | 消息完成 | - |
| extracted_info | 提取的结构化信息 | - |
| ask_user_options | 结构化采集请求 | ✅ |
| knowledge_reference | 知识库引用 | ✅ |
| consultation_state_patch | 状态增量更新 | ✅ |
| error | 错误 | - |
前端实现
前端 useSSEProcessor.ts 可以把 ask_user_options 事件转成 assistant-ui 的自定义 message part:
// 伪代码
function MessagePartRenderer({ part }) {
switch (part.type) {
case 'text':
return <Markdown>{part.text}</Markdown>;
case 'ask_user_options':
return (
<div className="options-group">
<p className="question">{part.question}</p>
<div className="options">
{part.options.map(opt => (
<Button
key={opt.value}
onClick={() => sendStructuredAnswer(part.field, opt.value)}
>
{opt.icon} {opt.label}
</Button>
))}
</div>
{part.allow_free_text && (
<FreeTextInput
placeholder={part.free_text_placeholder}
onSubmit={(text) => sendFreeText(part.field, text)}
/>
)}
</div>
);
}
}
MCP Elicitation 的借鉴
MCP(Model Context Protocol)的 Elicitation 设计正是这个方向:服务端可以向客户端请求额外用户信息,支持表单模式和 URL 模式,客户端负责展示交互界面、让用户检查和取消。
虽然不一定完整接 MCP,但可以借鉴它的思想:
- Agent 不直接假设用户会以什么格式回答
- 采集请求是结构化的,不是自然语言
- 客户端负责渲染,服务端负责语义
设计要点
| 要点 | 说明 |
|---|---|
| 选项数量 | 2~5 个最佳,超过 5 个用下拉 |
| 允许自由文本 | 始终提供”其他”或自由输入入口 |
| 跳过能力 | 用户可以跳过不回答 |
| 置信度 | 选项点击 > 自由文本解析 |
| 保留原文 | 自由文本同时记录原文和解析结果 |
| 渐进式 | 不要一次问太多,每轮 1~2 个问题 |