Day 33 Hard AI Backend RAG Agent Loop Embedding Serving

AI 产品后端 — RAG、Agent loop 服务化与人机协同AI Product Backend: RAG, Agentic Loops, Embedding Serving, Human-in-the-loop

问题场景 + 需求约束

50 万企业用户做一个「文档问答 + Agent 助手」后端:用户问问题,系统检索内部知识库回答;复杂任务(「把这三份合同的差异整理成表格并发邮件」)交给 agent 多步执行。这不是套个 LLM API 就完事——它同时是个检索系统、一个长任务编排系统、还要把不确定的模型输出关进可控的工程边界里

高层架构

graph TD U[用户请求] --> GW[API Gateway / 鉴权·限流] GW --> ORCH[Orchestrator
问答 or Agent 路由] ORCH -->|问答| RET[检索层] subgraph RET[检索层 ≤300ms] EMB[Embedding 服务
query 向量化] --> VDB[(向量库 ANN)] BM25[(BM25 倒排)] VDB --> FUSE[RRF 融合] BM25 --> FUSE FUSE --> RR[Cross-encoder 重排] end RR --> LLM[LLM 推理
streaming 首 token] ORCH -->|复杂任务| AG[Agent Loop
持久化执行引擎] AG --> TOOLS[Tool 调用
检索·DB·外部 API] AG -.高风险动作.-> HITL[人机协同
审批队列] HITL -.批准/拒绝.-> AG AG --> LLM LLM --> U INGEST[离线 Ingest
切块·嵌入·建索引] --> VDB INGEST --> BM25

三条主线:① 问答热路径走「检索→融合→重排→流式生成」,预算紧;② Agent 路径走持久化执行引擎,能跑很久、能中断恢复;③ 离线 Ingest 把文档变成可检索的向量+倒排,与在线解耦。下面拆四个关键技术点。

关键技术点

① RAG 检索质量 — 召回烂,模型再强也救不回来

一句话 trade-off:朴素切块嵌入便宜但召回差,contextual + hybrid + rerank 召回好但贵且慢。

【原理】RAG 的瓶颈几乎从来不是生成,而是检索召回——模型只能基于你喂给它的 chunk 回答,没召回到的内容等于不存在。朴素做法把文档按 512 token 切块、各自嵌入,问题是切块丢了上下文:一段写「该方案延迟降低 40%」,但「该方案」指谁、在哪份文档,孤立 chunk 里看不出来,语义检索就召不准。Anthropic 的 Contextual Retrieval 在每个 chunk 前拼一段 LLM 生成的「这段在讲什么」上下文摘要再嵌入,并叠加 BM25 关键词检索做 hybrid。

三种召回策略:
方案召回质量代价
朴素 chunk 嵌入基线最便宜,长尾/专名召不准
Contextual + Hybrid + Rerank失败率↓显著ingest 时每 chunk 多一次 LLM 调用 + 在线多一次 rerank
长上下文直接塞全文无检索误差token 成本爆炸、超窗就崩,1 亿 chunk 不可能
# 在线检索:hybrid 召回 + 重排(伪代码)
q_vec = embed(query)                      # query 向量化
dense = vdb.ann_search(q_vec, k=100)      # 语义召回 top-100
sparse = bm25.search(query, k=100)        # 关键词召回 top-100
fused = rrf(dense, sparse)                # 倒数排名融合,去重
top = cross_encoder.rerank(query, fused)[:8]  # 精排留 8 个最相关
context = "\n\n".join(c.text for c in top)
answer = llm.stream(prompt(query, context))   # 带出处流式生成
现实案例:Anthropic Contextual Retrieval 报告该方法把检索失败率降低约 49%,叠加重排后约 67%。原始 RAG 范式由 Lewis 等人 2020 年(NeurIPS)提出,把参数化知识与外部非参数检索库结合。检索质量与重排的更多细节见 Day 31「混合检索与重排序」。

② Agent Loop 服务化 — 把「跑很久还会崩」的循环变成可恢复的工作流

一句话 trade-off:内存态 loop 简单但进程一挂全丢,持久化执行可恢复但要把每步状态落盘。

【原理】Agent 是个循环:调模型 → 模型要求调工具 → 执行工具 → 把结果喂回 → 再调模型,直到完成。问题在于这循环可能跑几分钟、几十步,期间任何一步都可能:进程被部署重启、工具超时、模型 503。如果状态只在内存里,崩一次整个任务从头再来——而每一步都烧了真金白银的 token。解法是持久化执行(durable execution):把每一步的输入输出当成事件落盘,崩溃后从最后一个成功 step 重放恢复,已完成的工具调用不重跑(靠幂等键)。这正是 Temporal / AWS Step Functions 这类引擎的核心价值。

三种 agent 执行模型:
# 持久化 agent 循环(durable workflow 伪代码)
def agent_workflow(task):
    history = [user_msg(task)]
    for step in range(MAX_STEPS):             # 防失控的硬上限
        resp = activity.call_llm(history)     # 落盘;崩溃重放不重算
        if resp.is_final:
            return resp.text
        for call in resp.tool_calls:
            # 幂等键 = (workflow_id, step, tool, args_hash)
            result = activity.run_tool(call, idem_key=key(step, call))
            history.append(tool_result(call, result))
    raise StepLimitExceeded   # 触发降级/转人工
现实案例:Anthropic Building Effective Agents 区分了 workflow(预定义编排)与 agent(模型动态决定路径),并强调「能用简单方案就别上 agent」——agent 用延迟和成本换灵活性。生产中 agent 编排常落在 Temporal / Step Functions 这类持久化执行引擎上(与 Day 39「工作流引擎」同源)。

③ Embedding 服务设计 — 模型版本与索引版本必须绑死

一句话 trade-off:在线实时嵌入延迟低但贵,批量嵌入吞吐高但有延迟;最大的坑是换模型 = 整库重嵌入

【原理】Embedding 服务有两类流量:在线(query 向量化,要快,可缓存)和离线(ingest 批量嵌入 1 亿 chunk,要吞吐)。两者该分开部署:在线服务小批量低延迟,离线服务大 batch 打满 GPU。关键约束是同一个向量空间——query 和 doc 必须用同一个 embedding 模型嵌入,否则向量没法比较。所以一旦升级 embedding 模型,整个索引必须用新模型重建,且重建期间新旧索引要并存、灰度切换,不能中途混用(混用会让相似度彻底失真)。

致命陷阱:把 query 用新模型嵌、doc 还是旧索引——相似度计算出来是噪声,召回全错但系统不报错(没人崩溃,只是答案变烂)。务必给索引打 model_version 标签,query 端校验一致。
# 离线 ingest:批量嵌入 + 双索引灰度(伪代码)
for batch in chunks.stream(size=256):         # 大 batch 打满 GPU
    vecs = embed_offline(batch, model="v3")   # 与在线同一模型族
    new_index.upsert(batch.ids, vecs, meta={"model":"v3"})
# 换模型时:v3 索引后台建好 → 校验召回指标 → 原子切流量 → 删 v2
# 在线 query 端:
@cache(ttl="1h")                              # 热门 query 向量缓存
def embed_query(q): return embed_online(q, model=current_index_model())
在线 vs 离线嵌入:在线适合 query(QPS 高、文本短、可缓存命中高);离线适合 ingest(量大、可容忍分钟级延迟、批处理省钱)。别用在线服务跑全库重嵌入——会把在线 query 的延迟拖垮。
现实案例:向量库(pgvector / Pinecone / Milvus 等)普遍用 HNSW 做 ANN 索引,召回与延迟的取舍见 Day 12「搜索系统」。OpenAI / Cohere 的 embedding API 都明确标注模型版本,换代时官方文档强调需重建索引——这是行业通用约束,不是某家的特例。

④ 人机协同 — 把高风险动作关进审批门

一句话 trade-off:同步阻塞等人简单但占着连接/线程,异步挂起可扩展但要持久化整个 agent 状态。

【原理】模型会犯错,所以不可逆的高风险动作(发邮件、转账、删数据、改生产配置)不能让 agent 自己拍板。模式是 human-in-the-loop:agent 走到高风险工具时不直接执行,而是暂停,把「我打算做 X」推到审批队列,等人批准再继续。难点是「等人」可能等几小时甚至几天——你不能让一个线程/连接傻等。所以它必须建立在技术点 ② 的持久化执行之上:agent 状态落盘、释放所有资源、收到审批回调时从暂停点恢复

三种等待方式:
# 高风险工具 → 暂停等审批(durable 伪代码)
def run_tool(call):
    if call.tool in HIGH_RISK:
        approval_id = approvals.create(call)        # 推审批队列
        decision = workflow.wait_for_signal(         # 持久化挂起,可等数天
            approval_id, timeout="3d")
        if decision != "approved":
            return refused(call)                     # 拒绝则不执行
    return execute(call)                             # 批准/低风险才真正执行
现实案例:Anthropic Building Effective Agents 把「在关键节点设置人类检查点(checkpoints)」列为 agentic 系统的核心实践——尤其在动作不可逆或高代价时。这与 Day 7「分布式事务」的 Saga 补偿思路互补:能补偿的自动跑,不能补偿的(已发出的邮件)走人审批。

扩展与优化

常见陷阱 + 面试追问

深入资源

深入思考

1. 「长上下文模型越来越便宜,把整个知识库塞进 context、RAG 就过时了」——这个判断哪里对、哪里错?

错。即便 context 窗口足够大、足够便宜,RAG 仍有三个不可替代的理由:① 成本与延迟仍线性于 token——塞 1 亿 chunk 进每次请求,成本和首 token 延迟都爆炸,检索把它压到 top-8;② 中间遗忘,超长 context 里模型对中段信息的利用率显著下降,相关内容前置(检索做的就是这件事)反而更准;③ 可溯源与时效,RAG 天然给出「答案来自哪个 chunk」,且知识库更新即时生效,不用重训/重灌模型。

对的部分:检索粒度可以放粗——不必切 512 token 的小块,可以召回整节/整文档塞进大窗口,减少「切块丢上下文」的问题。所以趋势是 RAG 和长上下文融合(粗召回 + 大窗口精读),不是谁取代谁。

2. 你的 agent 在 step 7 调「发邮件」工具成功了,但 step 8 落盘前进程崩了。恢复时如何不重复发邮件?

核心是幂等,而非「记得已经发过」。持久化执行的恢复是重放:从最后一个成功落盘的 step 重新执行。如果 step 7 的工具结果已落盘,重放时引擎直接返回缓存结果、不重跑工具——邮件不会重发。危险窗口是「工具执行成功但结果还没落盘就崩」:此时重放会再次调用工具。

防御要靠工具侧幂等键:每次工具调用带 idem_key=(workflow_id, step, args_hash),邮件服务用它去重——同一个 key 第二次到达直接返回首次结果,不真正发送。这就是 Day 17「支付系统」幂等 recovery point 的同款思路:外部副作用必须自带幂等,光靠编排引擎记账不够,因为副作用与落盘之间永远存在裂缝。

3. 知识库每天更新 10 万文档,但你换了新 embedding 模型要全库重嵌入(1 亿 chunk,需 8 小时)。重建期间用户查询怎么办?

不能停服,也不能新旧混查(向量空间不兼容)。标准做法是双索引 + 原子切换:旧索引(v2)继续对外服务全部 query;新索引(v3)在后台用新模型从头重建,期间 query 端仍走 v2、query 也用 v2 模型嵌入(保持空间一致)。v3 建完先跑黄金评测集校验召回不退化,再原子地把流量(包括 query 嵌入模型)整体切到 v3,最后删 v2。

难点是重建期间的增量更新:这 8 小时里新进来的 10 万文档要同时写两个索引(v2 用旧模型、v3 用新模型),即双写,否则切到 v3 时会缺这段数据。双写要处理失败一致性(其中一个写失败要重试/补偿)。成本上,重建是一次性大批量离线作业,用 spot/批量 GPU 压成本,别占在线资源。

4. 为什么说「agent 的可观测性比传统微服务更难」?该埋哪些点?

难在非确定性 + 多步 + 高成本。传统服务一个请求一条 trace、行为可复现;agent 一个任务是动态展开的几十步树,同样输入下次可能走不同路径,且每步都烧 token——出问题时你要回答的不只是「哪步慢/错」,还有「为什么模型决定走这条路」「这步花了多少钱」。

该埋的点:① 每步的完整 trace(模型输入/输出、选了哪个工具、工具耗时与结果);② token 与成本归因到任务/用户/工具;③ 决策可回放,存下每步的 prompt+response 以便复盘「为什么这么决策」;④ 质量信号,检索召回率、答案是否带出处、人审批通过率、失控率(撞 step 上限的比例)。这是 Day 21「可观测性」的 metrics/logs/traces 三件套,但额外多了「成本」和「决策」两个维度。

5. 把语义缓存(相似 query 复用答案)上线后,命中率 30% 省了不少钱——但两周后开始有用户投诉「答案过时/答非所问」。可能哪里错了?

两个经典坑。① 缓存失效缺失:知识库更新了,但语义缓存里的旧答案没过期,用户拿到的是基于旧文档的结论。语义缓存的失效比普通 KV 缓存难——你不知道某条缓存答案依赖了哪些 chunk,所以要么给短 TTL、要么按知识库版本号整体 bump、要么记录答案的来源 chunk 并在那些 chunk 更新时定向失效。

② 相似度阈值太松:「答非所问」说明把不够像的 query 也判成命中了。「我们的退款政策是什么」和「我们的退货政策是什么」向量很近但答案不同。阈值太松会错配,太紧则命中率掉。要拿真实 query 对调阈值,且对高风险/精确类 query(涉及金额、政策、个人数据)禁用语义缓存,只对宽泛 FAQ 开。这正是 Day 2「缓存」里「容忍 stale 多久」和「缓存失效是难题」在 AI 场景的重演。