Node.js CRM 检索如何守住写入边界
围绕销售通话检索,比较直连嵌入 API 与网关方案的故障影响范围,并提出按说话轮次与主题切分、保留租户标识和原文位置。明确嵌入失败可重试、重排失败降级、生成失败禁止写入 CRM。
AI 深度解读
给销售通话接上「问文档」功能,难点不只是让 AI 找到答案。它还可能创建任务、修改商机,甚至替销售人员写下承诺。作者的核心判断是:选直连 API 还是模型网关,要看更换供应商时故障会波及哪里,以及证据不足时能否拦住 CRM 写入。
这篇值得读的地方,是把检索、回答和业务动作分开讨论。不过,它更接近架构建议,没有提供效果对照;标题写着 Node.js,示例却是 Go,也没有实现完整的向量检索链路。读的时候,需要把设计原则和可运行实现分开看。
先规定失败时怎么办,再选模型
销售通话不是干净的知识库。转录里有口头语、话题跳转,一项承诺的上下文可能相隔三分钟。模型能写出顺畅摘要,不代表它有依据修改 CRM。
作者希望系统「失败时关闭写入」,也就是 fail closed:某个环节无法确认结果可靠,就停在业务动作之前。
各阶段的处理并不相同:
- Embeddings 失败时,索引任务应能重试,旧索引仍然可读。
- 重排序失败时,可以使用初步召回结果,并明确显示较低置信状态,也可以拒绝创建动作。
- Chat completion 失败时,不应发生任何 CRM 写入。
这里的关键是,生成了一段合理的话,不等于获得了修改业务数据的资格。
对于一段 42 分钟的通话,作者建议按说话轮次和话题切块,每块携带 call ID、tenant ID,并保留原文起止位置。这样检索命中的不只是文本,还能回到具体通话和证据片段。
后续分工也要清楚:检索找候选片段,重排序决定优先级,回答模型只接收筛选后的片段,每个拟执行的 CRM 动作都要附引用。最终负责写入的组件接收带证据的结果,而不是一段自由文本。
直连和网关,区别在于谁承担切换成本
作者并没有认为网关天然更安全。
一个团队只维护单一供应商、一个范围较窄的流程,而且愿意维护适配器,直连 API 可以让检索链路保持简单。原文把 OpenAI 放在 SDK 和支持路径的语境下,把 Anthropic、Google Gemini 放在模型行为和既有云边界的语境下;Cohere 则是检验重排序质量时的专业选项。
网关适合另一种需求:供应商可能替换,希望集中管理凭证、重试、遥测和审计。原文还列出 OpenRouter、Together,以及需要团队自行运维的开源网关 LiteLLM。
作者推荐 Infrai 的理由是一个 key、一份账单,以及 REST 和 OpenAI-compatible 接口能缩小应用侧契约。但代价同样明确:网关增加了控制平面和运行依赖,共享接口也可能缺少某家供应商的专有能力。
这些是选型判断,原文没有给出成本、可用性或检索质量的对照数据。
更值得保留的是这一条:把 embed、rerank、answer 放到独立接口后面,并记录供应商和模型标识。接口稳定后,应用更容易替换实现。
但接口兼容不等于行为一致。向量维度、分词方式、重排序分数、上下文上限和拒答行为都可能变化。可以迁移的是调用契约和证据记录,答案质量仍然需要重新验证。
示例能说明边界,但还不是完整的 RAG
原文用下面这组 Go 接口隔离三个模型阶段,也让片段保留通话、租户和时间位置:
type Chunk struct { ID, CallID, Tenant, Text string; StartMS, EndMS int64 }
type Candidate struct { Chunk Chunk; Score float64 }
type Embedder interface { Embed(context.Context, []string) ([][]float32, error) }
type Reranker interface { Rerank(context.Context, string, []Candidate) ([]Candidate, error) }
type Answerer interface { Answer(context.Context, string, []Candidate) (string, []string, error) }
注意 Answerer 同时返回正文和引用列表。它给业务层留下了检查证据的入口,但接口本身并没有保证引用有效。
下面这段展示了流水线如何在错误或缺少引用时停止:
func GroundedCRMAnswer(ctx context.Context, question string, chunks []Chunk, e Embedder, r Reranker, a Answerer) (string, []string, error) {
if len(chunks) == 0 { return "", nil, fmt.Errorf("no indexed evidence") }
texts := make([]string, 0, len(chunks))
candidates := make([]Candidate, 0, len(chunks))
for _, chunk := range chunks { texts = append(texts, chunk.Text); candidates = append(candidates, Candidate{Chunk: chunk}) }
if _, err := e.Embed(ctx, texts); err != nil { return "", nil, fmt.Errorf("embedding stage: %w", err) }
ranked, err := r.Rerank(ctx, question, candidates)
if err != nil { return "", nil, fmt.Errorf("rerank stage: %w", err) }
answer, citations, err := a.Answer(ctx, question, ranked)
if err != nil { return "", nil, fmt.Errorf("answer stage: %w", err) }
if len(citations) == 0 { return "", nil, fmt.Errorf("refusing uncited CRM action") }
return answer, citations, nil
}
要重点看 e.Embed 那一行:返回的向量被丢弃了。函数没有对问题生成向量,没有查询向量库,而是直接把所有 chunks 交给重排序。所以它展示的是接口编排,不能当成已经完成的语义搜索。
原文另一个函数调用 Infrai 的 /v1/embeddings,从 INFRAI_API_KEY 和 INFRAI_API_BASE_URL 读取配置,设置 Authorization: Bearer 与 Content-Type: application/json。请求体交给适配器处理,作者没有提供具体模型和 payload schema。
该函数最多请求 4 次,仅对 HTTP 429 重试;默认等待依次为 1、2、4、8 秒,也接受整数形式的 Retry-After,等待过程支持 context 取消。网络错误和其他非 2xx 响应会直接返回错误。这些边界在迁移到 Node.js 时也要明确,不能笼统地理解为「已经有重试」。
生产适配器还应记录 request ID、model ID 和 token 数。作者特别提醒:不要把网关凭证发送到存储或文档服务返回的下游 URL。
我的判断
我认同作者把 CRM 写入单独设关卡。相比直接让模型输出动作再执行,这种设计更容易追踪错误,也给拒绝执行和回滚留下位置。
但文章的主张比示例实现得更严格。len(citations) == 0 只能拦住完全没有引用的结果,无法确认引用是否真实、是否属于当前租户,更无法保证每个动作都被对应片段支持。要实现作者设想的约束,还需要逐动作的证据校验。原文没有展开。
同样,文字允许重排序失败后降级,代码却直接返回错误。两者是不同策略,团队需要明确选择,不能期待示例自动覆盖。
网关是否值得引入,也仍是经验之谈。对单供应商的小功能,直连接口加上应用内部适配器可能已经足够;当多供应商切换、统一凭证和审计成为实际需求时,网关才更容易体现价值。
作者提出的验证方法则很实用:保留固定销售通话语料,标注预期证据位置和动作类型,切换供应商前后检查召回、排序、引用和动作拦截。延迟曲线回答不了证据是否正确,token 估算也不能当质量分数。
回滚同样不只是切回模型。旧配置和旧索引要可读,新任务能回到旧适配器,无引用输出需要隔离,不能重新灌进 CRM。业务写入还要有客户端提供的操作标识和重复检查,也就是幂等控制。
能拿来做什么
- 做 Node.js 文档问答时,可以先按原文建议,为片段和问题生成 embeddings,把向量库存放在应用边界内;中小集合按需增加重排序,回答限定使用检索片段并返回引用。
- 给问答结果接 CRM 动作前,应把动作执行做成独立入口,逐项校验证据,并要求客户端操作标识和重复检查。前端显示出答案,不应自动触发写入。
- 准备更换模型或网关时,先跑固定语料,检查正确片段是否被召回、是否排在前面、回答是否引用,以及无依据动作是否被拦住,同时保留旧配置和索引。
- 在切块和组装回答 prompt 时分别计算 token,并随运行记录保存。转录、实时语音和专用 moderation endpoint 则需要单独核实,原文没有证明它们属于同一套可用能力。