架构
热路径和延迟
热路径是访客消息 → 首个 token 管道。它有严格的 1 秒 p95 契约——超过这个时间,感知的"这东西还活着吗?"的紧张感就会破裂。 所有不必在热路径上发生的事情都被推离它。
延迟预算
首个 token 在 p95 的目标分解:
| 阶段 | p95 目标 |
|---|---|
| HTTP 接收 + 身份验证 | 30 ms |
| 精选短路检查 | 5 ms |
| 嵌入查询 | 120 ms |
| 向量搜索(ANN) | 80 ms |
| 重新排名 | 120 ms |
| 提示词组装 | 10 ms |
| LLM 到首个 token 的时间 | 500 ms |
| 总计 | ~865 ms |
其余部分的余量 < 150ms。首个 token 之后的任何内容都会增量流式传输—— 完整响应时间由 token 速率限制,而不是预算。
硬性规则
这些由代码审查和测试强制执行:
- 热路径上无 DB 写入。持久化在流结束后异步进行。
- 无同步 webhook。出站 webhook 作为队列作业分发。
- 无重试。如果提供商在流中间失败,用户看到优雅的错误,小部件在客户端自动重试。服务器不循环。
- 无 N+1 查询。所有读取都是批量的。最近的历史来自 Redis(
conv:{id}:history),而不是 Postgres。 - 每轮一次 LLM 调用。没有多步骤智能体推理扇出到多个模型调用。
什么不在热路径上
以下所有内容都在流完成后之后分发。它们都不会阻塞访客:
- PersistTurnJob ——保存用户 + 助手消息。
- IncrementUsageJob ——增加工作区的每月对话计数器。
- DetectGapJob ——聚类低置信度问题用于缺口报告。
- DispatchWebhookJob ——分发到订阅的客户端点(工作流侧;当访客提交潜在客户表单时,lead-captured webhook 通过
SignedDispatcher内联触发)。 - AutoIndexPageVisit ——在
/init时触发的同步服务,当启用自动索引时为访问的 URL 排队CrawlPageJob。不是热路径作业,但值得了解自动索引运行的位置。
缓存
两个缓存保持热路径紧凑:
- 检索缓存 ——Redis,
rag:retrieve:{agentId}:{hash(query|currentPageUrl)},30 分钟 TTL。同一页面上的相同问题命中缓存。当知识源更改时失效。 - 对话历史缓存 ——Redis,
conv:{convId}:history,2 小时 TTL,上限为 12 条消息(6 轮)。每轮都从这里读取而不是 Postgres。
流式机制
SSE 非常简单——保持活动的 HTTP,每个 token 写入 data: {...}\n\n,
刷新。小部件通过 EventSource 读取(或在没有 POST 上的
EventSource 的旧浏览器中通过 fetch + reader)。
关键是,SSE 响应在任何 RAG 工作运行之前构建。我们在收到请求时立即 开始写入标头,以便我们前面的任何代理(Cloudflare、负载均衡器)尽早承诺流式传输。 当 token 到达时,连接已经打开。
span 的位置
OpenTelemetry span 包装每个阶段:
widget.message.receiverag.curated.matchrag.embedrag.vector.searchrag.rerankrag.prompt.assemblerag.llm.first_tokenrag.llm.streamrag.persist.async
Honeycomb / Grafana 显示每个的 p95。当预算破裂时,span 热力图通常直接指向违规者。
故障模式
| 故障 | 行为 |
|---|---|
| LLM 提供商在流中间 5xx | 流发出 error 事件。小部件自动重试最多 3 次。 |
| 向量存储无法访问 | 管道返回没有基础的问题。置信度为 0.3 → low_confidence 标志 → “我不知道”答案。 |
| 嵌入调用超时 | 相同——在没有基础的情况下继续,标记 low_confidence。 |
| 配额超出 | 在 /init 捕获,永远不会到达消息。返回 429。 |
原则:访客总是得到响应,即使是"我不确定"。允许智能体无知; 不允许它静默破裂。