构建问答机器人
本指南构建一个机器人的核心部分,它根据您的知识库回答问题:可以在聊天工具中,在帮助中心里,也可以在您的产品内部。助手查找相关知识并作答,同时引用所用的卡片;您的代码负责发送问题并展示结果。
需要一个具有 search 和 ask 权限范围的密钥。回答只使用密钥有权读取的卡片,因此密钥的角色、数据权限档案和合集决定机器人可以谈论哪些内容。面向客户的机器人,请使用只能看到公开合集的密钥。
获取回答的三种方式
| 接口 | 返回 | 适用场景 |
|---|---|---|
POST /v1/search | 匹配的摘录,最匹配的在前。没有写好的回答。 | 搜索框、“相关文章”、您自己的排序。 |
POST /v1/ask | 一个响应中返回完整的回答及其来源。 | 每个回答发一条消息的机器人;后端作业。 |
POST /v1/chat | 在回答生成的同时,以事件流的形式返回回答。 | 聊天界面,让用户看到回答逐步出现。 |
搜索
curl -X POST https://api.sursa.com/v1/search \
-H "Authorization: Bearer $SURSA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "how do I return a damaged item", "collection": "support"}'每个命中结果都是卡片中的一段摘录:卡片的 doc_id 和 doc_name、所在章节的 heading、摘录的 text、找到它的页码或表格行,以及卡片的可信状态和核实情况。
collection 选择要搜索的合集;不传时使用名为 default 的合集。tags 和 card_ids 把搜索范围缩小到带有这些标签的卡片或指定的卡片。
提问并获取完整回答
curl -X POST https://api.sursa.com/v1/ask \
-H "Authorization: Bearer $SURSA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "How do I return a damaged item?", "collection": "support"}'answer 中的回答正文是 Markdown。其中的引用(例如 [1] 或 [1, 3])对应 sources 中各项的 ref;请把每个引用链接到对应的卡片(doc_id),方便读者核对回答。
正文中无法与文档相互印证的数字,可能显示为 ⟦n:a1⟧ 这样的标记。verification.items 描述每个标记:其 marker、reason 和 original 文本。如果代码不处理标记,请把每个标记替换为其 original 文本。
没有回答时
响应始终为 200。助手无法回答时,empty 说明原因,answer 中有一条可以直接显示的简短消息:
empty | 含义 |
|---|---|
no_hits | 知识库中没有与问题相关的内容。 |
restricted | 相关主题在密钥无权读取的卡片中。 |
guardrail | 问题超出了组织允许助手回答的范围。 |
no_data_for_period | 问题涉及的时间段内没有文档。 |
no_hits 是一个有用的信号:它表明用户在问哪些知识库尚未涵盖的内容。
Fast 与 Precise
对于较难的问题,传入 "quality": "precise" 可以得到更全面的回答,但耗时更长。默认值 "fast" 适合大多数问题。
以流式返回回答
在聊天界面中,请在回答生成的同时显示回答。带 "stream": true 的 POST /v1/chat 以 text/event-stream 响应:每条消息一个事件,每个事件有一行 id:(它在流中的编号,从 1 开始)和一行包含 JSON 的 data:。JSON 的 type 字段说明事件的类型。
id: 1
data: {"type":"meta","sources":3,"timings":{…},"answer_id":"…"}
id: 2
data: {"type":"sources","sources":[{"ref":1,"doc_id":"8c1d…","doc_name":"Returns","text":"…"}]}
id: 3
data: {"type":"token","text":"Send a photo of the item "}
id: 4
data: {"type":"token","text":"within 7 days [1]."}
id: 5
data: {"type":"done","answer":"Send a photo of the item within 7 days [1].","question_id":9812,"sources":[…]}
type | 处理方法 |
|---|---|
meta | 回答已开始。如果要记录回答,请保存 answer_id。 |
sources | 回答所依据的卡片。可能出现多次,以最后一次为准。 |
token | 回答正文的一部分。把它追加到已显示的内容后面。 |
done | 回答已完成。其字段与 /v1/ask 的响应相同。 |
error | 回答已中止。流在没有 done 的情况下结束。见下文。 |
followups | 在 done 之后:建议的后续问题,可以作为按钮提供给用户。 |
| 其他任何类型 | 跳过。随时可能新增事件类型。 |
以 : 开头的行(例如在准备回答期间每 15 秒发送一次的 : ping)是注释,请跳过。
messages 包含到目前为止的对话,最早的在前,最后是用户的问题。请发送最近几轮对话,这样“那海外订单呢?”这类追问才能被理解。
// Streams an answer and calls onText with each piece of text. Node.js 20+ or a browser.
export async function streamAnswer({ messages, collection, requestId, lastEventId, onText }) {
const response = await fetch("https://api.sursa.com/v1/chat", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SURSA_API_KEY}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
...(lastEventId ? { "Last-Event-ID": String(lastEventId) } : {}),
},
body: JSON.stringify({ messages, collection, stream: true, request_id: requestId }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
const decoder = new TextDecoder();
let buffer = "";
let event = { id: 0, data: "" };
let done = null;
for await (const piece of response.body) {
buffer += decoder.decode(piece, { stream: true });
let newline;
while ((newline = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, newline).replace(/\r$/, "");
buffer = buffer.slice(newline + 1);
if (line.startsWith("id:")) event.id = Number(line.slice(3).trim());
else if (line.startsWith("data:")) event.data += line.slice(5).trim();
else if (line === "" && event.data) {
const message = JSON.parse(event.data);
if (message.type === "token") onText(message.text, event.id);
else if (message.type === "done") done = message;
else if (message.type === "error") throw Object.assign(new Error(message.message), { event: message, id: event.id });
event = { id: 0, data: "" };
}
}
}
if (!done) throw new Error("the stream ended before the answer was complete"); // resume it
return done;
}不带 "stream": true 时,POST /v1/chat 与 /v1/ask 一样,以 JSON 返回完整的回答。
恢复中断的流
连接会断开:移动网络切换、代理超时、您的服务器重启。为每个问题指定 request_id,回答就能在断开后继续:
- 带有
request_id时,回答的准备过程与连接无关。断开连接不会中止回答:回答会完成并被保存。 - 要恢复,请再次发送同样的请求,使用同一个
request_id,并在Last-Event-ID头中传入收到的最后一个事件的编号。 - 每个问题使用一个
request_id(例如 UUID),该问题的每次重试都使用同一个值。新问题使用新的request_id。
重复的请求会收到什么,取决于回答所处的阶段:
| 情况 | 重复的请求收到的内容 |
|---|---|
| 回答仍在生成中,或刚刚完成 | Last-Event-ID 之后的事件,编号不变:流直接继续。 |
| 回答在几分钟前已完成 | 已保存的回答:一个包含完整正文的 token 和一个 done,从 1 开始编号。不会重新提问。 |
| 流无法从该编号继续 | 从头开始的回答,从 1 开始编号。 |
代码用一条规则即可处理这三种情况:如果收到的第一个事件的编号不大于 Last-Event-ID,说明回答从头开始;清除已显示的内容,重新构建。否则,追加到已显示的内容后面。
import { randomUUID } from "node:crypto";
async function answerWithResume(messages, collection, show) {
const requestId = randomUUID();
let text = "";
let lastId = 0;
for (let attempt = 1; ; attempt++) {
let first = true;
try {
return await streamAnswer({
messages, collection, requestId, lastEventId: lastId,
onText(piece, id) {
if (first && lastId && id <= lastId) text = ""; // the answer starts over
first = false;
text += piece;
lastId = id;
show(text);
},
});
} catch (error) {
const retry = error.event ? error.event.retry === true : true; // a network error, or a server restart
if (!retry || attempt === 5) throw error;
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
}
}
}Sursa API 可能运行在多台服务器上。只有在正在生成该回答的服务器上,流才能从 Last-Event-ID 继续;如果重复的请求到达了另一台服务器,它会在回答完成后收到已保存的回答,或者回答从头重新生成。上面的规则涵盖这两种情况,而且问题绝不会被计数两次。
不带 request_id 时,关闭连接会取消回答。
流中的错误
流开始之前的错误是普通的 HTTP 错误:401、403、422,或者在组织已用完提问限额或同时进行中的回答过多时,返回带有 Retry-After 的 429。
流开始之后,其 HTTP 状态始终为 200,问题以 error 事件的形式到达:
{"type": "error", "code": "server_restart", "retry": true, "message": "…", "answer_id": "…"}
retry: true表示回答因服务器原因中止(例如在更新期间),可以原样再次发送同一个请求。- 其他代码(例如
timeout)列在 API 参考中。不认识的代码按其他流错误处理:告知用户,并让用户重新提问。
让用户为回答评分
每个回答都有 question_id。把用户的评分发送到 POST /v1/questions/{question_id}/feedback:1 表示有帮助,-1 表示没有帮助,可以附带 reason 和 note。评分可以帮助组织发现哪些卡片需要改进。
curl -X POST https://api.sursa.com/v1/questions/9812/feedback \
-H "Authorization: Bearer $SURSA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"value": -1, "reason": "outdated", "note": "The return window is now 14 days."}'生产环境机器人的检查清单
- 密钥只有
search和ask,并限制在机器人受众可以查看的合集内。 - 每个问题一个
request_id,并采用上面的恢复规则。 - 读取流的超时时间长于 15 秒,让
: ping注释保持连接。 - 回答按 Markdown 渲染,引用链接到其来源。
- 设置了
empty时显示清晰的提示,并提供联系人工的途径。 - 处理
429时等待Retry-After。 - 把评分发送回 Sursa,让知识库随时间不断改进。