写在前面

做少儿跑酷教练知识库问答助手项目时,我接触到了一个很典型的 AI 应用场景:用户在小程序里提出和课程、等级、动作相关的问题,系统根据已有知识库返回回答。

这个模块的实现方式不是从零搭建一套向量数据库和 RAG 服务,而是基于扣子工作流完成知识库问答能力。业务侧主要负责把用户问题和会话信息传给工作流,再把工作流返回的结果通过 WebSocket 流式输出给前端。

一开始我对它的理解比较偏“调用一个 AI 工作流接口”。但如果想真正理解这类 AI 应用,不能只停在调用层,还要知道几个问题:

  1. 为什么知识库问答不能直接把问题发给大模型?
  2. 扣子工作流在这条链路里解决了什么?
  3. RAG 背后的检索增强生成思路是什么?
  4. WebSocket 流式输出为什么会让前端状态变复杂?

这篇文章就从“调用扣子工作流”这个真实入口出发,整理我对知识库问答的理解。

为什么不直接问大模型

假设用户问:

L2 阶段的孩子适合练哪些跑酷动作?

如果直接把这个问题发给通用大模型,它可能会给出一套看起来合理的训练建议。但这个回答不一定符合具体机构自己的课程体系。比如:

  • L1、L2、L3 每个等级分别对应哪些动作。
  • 某个动作是否已经纳入当前课程。
  • 不同年龄段是否有安全限制。
  • 教练希望使用怎样的教学话术。
  • 某些训练动作是否需要特定器械或保护措施。

这些信息不是模型“聪不聪明”的问题,而是模型有没有拿到业务资料的问题。

知识库问答要解决的就是:让模型回答时尽量基于已有资料,而不是凭通用知识猜。

整体调用链路

这个问答模块可以拆成三层:

层级 主要职责
小程序前端 输入问题、展示回答、维护会话状态、处理 loading、失败、重试和流式渲染
业务服务层 登录校验、会话管理、工作流调用、WebSocket 事件转发、错误封装
扣子工作流 知识库检索、Prompt 编排、模型调用、条件分支和兜底回复

用户发送问题后的链路大概是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户输入问题

前端本地插入用户消息和 AI 占位消息

通过 WebSocket 发送问题、会话 ID、工作流 ID

业务服务调用对应的扣子工作流

扣子工作流完成知识库检索和模型生成

业务服务把结果转成 WebSocket 流式事件

前端逐段追加回答内容

回答完成后更新消息状态

这里比较关键的是:前端并不直接调用扣子平台,也不直接管理知识库。前端关心的是用户问题、会话、工作流选择和实时展示;扣子工作流负责 AI 编排;业务服务负责把两者连接起来。

扣子工作流在这里解决了什么

扣子工作流更像是一个可视化的 AI 编排工具。它不是只调用一次模型,而是可以把一次问答拆成多个步骤:

1
2
3
4
5
6
7
8
9
10
11
接收用户问题

知识库检索

判断是否命中有效资料

命中:组织 Prompt 并调用模型

未命中:返回兜底话术或推荐问题

格式化输出

这对业务项目很有价值。因为很多时候我们并不想一开始就自己维护向量数据库、Embedding 服务和复杂的 AI 编排逻辑。先用工作流平台把知识库问答跑通,可以更快验证这个功能是否真的有用。

但使用工作流不代表不需要理解原理。回答不准时,仍然要知道从哪些环节排查:知识库内容是否完整、检索是否命中、Prompt 是否约束清楚、输出格式是否稳定、前端是否正确展示。

扣子工作流可以怎么搭建

一个知识库问答工作流可以按下面的思路搭建。

1. 准备知识库

以少儿跑酷教学场景为例,知识库内容可以包括:

  • 会员等级说明。
  • 不同等级对应的训练动作。
  • 动作教学说明。
  • 训练安全注意事项。
  • 常见问题与标准回答。

资料质量会直接影响回答效果。如果知识库内容本身不完整,后面再怎么调 Prompt,也很难生成准确回答。

2. 设计工作流输入

工作流至少要接收用户问题:

1
2
3
{
"question": "L2 阶段适合练哪些动作?"
}

如果业务上下文更复杂,也可以传入更多信息:

1
2
3
4
5
6
{
"question": "这个动作怎么练?",
"level": "L2",
"currentAction": "猫爬",
"sessionId": "xxx"
}

这些上下文能帮助工作流理解用户问题,尤其是“这个”“那个”这类指代不清的表达。

3. 添加知识库检索节点

知识库检索节点的作用是:根据用户问题,从已有资料中找出最相关的片段。

比如用户问“L2 阶段适合练哪些动作?”,检索节点应该尽量命中:

1
2
L2 阶段包含猫爬、基础翻越、平衡训练……
L2 阶段训练时需要注意落地缓冲和教练保护……

这里通常要关注:

  • 检索哪个知识库。
  • 返回多少个相关片段。
  • 相似度低于多少认为未命中。
  • 是否返回来源信息,方便后续排查。

4. 增加条件判断

检索并不总是成功。工作流里最好加条件判断:

1
2
3
4
如果命中有效资料
→ 进入模型回答节点
否则
→ 返回兜底话术或推荐问题

这样可以避免知识库没有资料时,模型仍然强行编一个答案。

5. 编写模型节点 Prompt

模型节点的 Prompt 要明确约束回答范围。一个简化版本可以是:

1
2
3
4
5
6
7
8
9
你是少儿跑酷教学助手。
请根据给定资料回答用户问题。
如果资料中没有答案,请说明暂未找到相关内容,不要编造。

资料:
{{knowledge}}

用户问题:
{{question}}

这里最重要的是“根据给定资料回答”和“资料中没有答案就说明未找到”。知识库问答宁愿保守一点,也不要生成看似专业但没有依据的内容。

6. 设计输出格式

为了让业务服务和前端更容易处理,工作流最好输出稳定结构,而不是完全自由文本。

例如:

1
2
3
4
5
6
7
8
{
"answer": "L2 阶段可以重点练习猫爬、基础翻越和平衡训练……",
"hit": true,
"suggestions": [
"L2 阶段训练前需要注意什么?",
"猫爬动作怎么练?"
]
}

这样服务层可以根据结果决定:是正常流式输出答案,还是返回推荐问题卡片。

RAG:工作流背后的知识库问答原理

扣子工作流帮我们屏蔽了很多底层细节,但知识库问答背后常见的思路仍然是 RAG,也就是 Retrieval-Augmented Generation,可以理解成“检索增强生成”。

一个简化版 RAG 流程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
资料入库阶段:
业务文档

清洗

切分成 chunk

向量化

写入知识库或向量索引

用户提问阶段:
用户问题

问题向量化

检索相关 chunk

筛选和排序

拼接 Prompt

模型生成回答

它和普通聊天最大的区别是:模型不只看到用户问题,还会看到从知识库里找出来的上下文。

1. chunk:知识库的最小检索单元

知识库通常不会把整篇文档一次性丢给模型,而是先切成多个片段。这个片段通常叫 chunk。

比如动作资料可以这样切:

chunk_id title content metadata
c001 L2 猫爬训练 L2 阶段可以练习猫爬,重点是手脚协调和身体稳定 level=L2, type=action
c002 L2 基础翻越 基础翻越需要注意支撑点、重心转移和落地缓冲 level=L2, type=action
c003 训练安全规则 训练前需要热身,高风险动作必须由教练保护 type=safety

chunk 切得太大,会带入很多无关内容;切得太小,又可能丢失上下文。所以比较理想的方式是按业务语义切分,比如按等级、动作、注意事项来拆。

2. Embedding:把文本变成可以比较的向量

Embedding 可以粗略理解为“把文本变成一组能表达语义的数字”。这样系统就不只能做关键词匹配,还能找到语义相近的内容。

例如:

1
“L2 阶段适合练哪些动作”

和:

1
“L2 猫爬训练、基础翻越、平衡训练”

表面文字不完全一样,但语义相关。Embedding 的作用就是让系统能找到这种语义相关的资料。

3. TopK:不是只找一个答案

检索时通常不会只取一个片段,而是取 TopK 个相关片段。比如 Top 5。

原因是用户问题可能同时涉及多个方面:

  • 动作列表。
  • 训练注意事项。
  • 适合年龄。
  • 安全保护。

多个片段一起提供给模型,回答会更完整。但 TopK 也不能太大,否则上下文里会混入噪声。

4. 相似度阈值:控制“没找到”的边界

知识库问答需要有“我不知道”的能力。如果检索结果相似度很低,就应该认为没有可靠资料,而不是强行回答。

可以把逻辑理解成:

1
2
3
if (retrievedChunks.length === 0 || maxScore < threshold) {
return fallbackAnswer();
}

这也是工作流里需要条件判断节点的原因。

5. Prompt 拼接:让模型基于资料回答

检索到资料后,需要把资料和用户问题一起组织成 Prompt。

1
2
3
4
5
6
7
8
9
你是少儿跑酷教学助手。
请只根据资料回答,不要编造。

资料:
1. L2 阶段包含猫爬、基础翻越、平衡训练。
2. L2 阶段训练时需要注意落地缓冲和教练保护。

问题:
L2 阶段的孩子适合练哪些跑酷动作?

这样模型回答时就有了依据。

为什么用 WebSocket 做流式输出

AI 回答可能需要几秒甚至更久。如果等完整答案生成后再返回,用户会感觉页面卡住。流式输出的体验会更自然:模型生成一点,前端展示一点。

在这个项目里,问答结果通过 WebSocket 推回前端。可以把事件理解成:

1
2
3
4
5
6
7
8
9
10
11
发送问题

新会话创建成功

收到第 0 个回答片段

收到第 1 个回答片段

……

回答完成

一个简化的流式事件可以设计成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type StreamChunk = {
requestId: string
sessionId: string
streamId: string
chunkIndex: number
content: string
}

type StreamDone = {
requestId: string
sessionId: string
totalChunks: number
content: string
}

前端收到每个 chunk 后,把 content 追加到当前 AI 消息上。收到 done 后,再把消息状态从 streaming 改成 success

前端流式渲染的几个难点

1. 本地先插入占位消息

用户点击发送后,不能等服务端返回才显示消息。更好的体验是:

1
2
3
4
5
6
7
8
9
插入用户消息

插入 AI 占位消息

等待流式片段

逐段追加回答

标记完成

这样用户马上能看到自己的问题,AI 回答也有固定位置承接后续内容。

2. 新会话需要临时 ID

如果用户在一个新会话里第一次提问,前端发送消息时,服务端真实会话可能还没有创建出来。这个时候前端可以先用一个临时会话 ID 存放消息。

等服务端返回真实会话 ID 后,再把临时会话里的消息迁移过去。

这个设计解决了一个常见体验问题:用户刚发出第一条消息时,页面不能空等服务端创建会话。

3. chunk 需要幂等处理

实时通信里最容易出问题的是重复消息。比如网络断开后重连,服务端可能会补发一部分内容。如果前端直接追加,就会出现重复文字。

可以用 requestId + chunkIndex 做幂等判断:

1
2
3
4
5
6
7
8
const processedChunkIndex = new Map<string, number>();

function shouldAppendChunk(requestId: string, chunkIndex: number) {
const lastIndex = processedChunkIndex.get(requestId) ?? -1;
if (chunkIndex <= lastIndex) return false;
processedChunkIndex.set(requestId, chunkIndex);
return true;
}

这个细节和 AI 模型本身关系不大,但会直接影响用户看到的结果是否稳定。

4. done 事件要负责收尾

流式输出不是把文字拼完就结束。收到完成事件后,还要做收尾:

  • 把消息状态改成成功。
  • 清理当前请求的运行状态。
  • 清理 chunk 去重记录。
  • 如果有图片或推荐选项,更新到消息里。
  • 如果有未完成的超时计时器,也要清掉。

否则下一次发送消息时,可能会被旧状态影响。

断线恢复:不只是重新连上

WebSocket 断开后,重新连接只是第一步。更重要的是:断线期间服务端有没有继续生成内容?前端有没有错过消息?

一种常见做法是记录当前会话最后处理到的 stream 信息。重连时告诉服务端:

1
我在这个 session 里最后处理到 lastStreamId

服务端根据这个信息返回错过的消息,前端再把这些消息重新走一遍原来的事件处理流程。

这样做的好处是:补发消息可以复用已有的流式处理逻辑,而不是单独写一套恢复逻辑。

多端在线:为什么会有只读模式

这个项目里还涉及同一个账号多端在线的问题。如果两个设备同时进入同一个会话,并且都能发送问题,就可能出现上下文冲突。

一种处理方式是给会话里的设备分角色:

  • chatter:可以发送问题。
  • viewer:只能查看,不能发送。

当服务端判断当前设备只能浏览时,前端就要禁用输入框或发送按钮,并提示用户当前是只读模式。

这类设计看起来和 AI 没关系,但对真实问答产品很重要。因为 AI 对话天然依赖上下文,多端同时写入很容易让会话状态变乱。

回答不准时怎么排查

知识库问答效果不好时,不一定是模型的问题。可以按链路拆开看。

1. 工作流是否选对

如果业务里有多个工作流,前端发送问题时需要带上正确的工作流 ID。选错工作流,后面的知识库和 Prompt 都可能不对。

2. 用户问题是否清晰

如果用户只问“这个怎么练”,系统可能不知道“这个”指什么。可以在前端引导用户补充信息,或者由工作流先做问题改写。

3. 知识库是否有内容

如果知识库里根本没有 L2 阶段的动作说明,模型就没有可靠依据。这个时候应该补知识,而不是只调 Prompt。

4. 检索是否命中

知识库里有内容,不代表一定能检索出来。标题、关键词、同义词、切分方式、metadata 都会影响命中效果。

5. WebSocket 推送是否完整

工作流返回了正确内容,不代表前端一定能看到正确内容。还要检查是否出现了漏推、重复推、乱序推,以及前端是否正确处理 chunk。

6. 前端状态是否清理干净

如果请求运行态、chunk 去重记录、临时会话映射没有清理干净,下一次请求可能会被旧状态影响。

这次学习后的理解

这次梳理之后,我对“调用 AI 工作流”有了更具体的理解。

它不是简单地把问题发给一个接口,而是一条完整链路:前端负责交互和状态,业务服务负责鉴权、会话、协议转换和错误处理,扣子工作流负责知识库检索和模型生成,WebSocket 负责把生成过程实时推给用户。

对于知识库问答来说,真正影响效果的不只有模型能力,还包括知识库质量、chunk 切分、检索命中率、Prompt 约束、未命中兜底、流式展示、错误恢复和状态清理。