本章涵盖:
- LLM 如何扩展传统 MLOps 基础设施与实践
- 从文档摄取到响应生成,构建一个 RAG 系统
- 通过版本控制与测试实现提示工程(prompt engineering)工作流
- 为多步骤 LLM 推理链搭建可观测性
在本书中,我们已经为 ML 工程打下了全面基础——从容器化部署到监控流水线。但这个领域仍在快速演进,而大语言模型(LLMs)代表了自深度学习兴起以来,我们构建 AI 应用方式中最重要的一次转变。
LLM 带来了新的机会与挑战,推动我们把传统机器学习运维(MLOps)实践继续延展。你学到的基础依然关键——可靠基础设施、系统化部署、持续监控——但 LLM 引入了独特的运维考量,需要更进化的方法:非确定性输出打破传统测试假设;复杂的多步骤推理链需要新的调试策略;提示工程成为关键学科;以及安全性问题超越了单纯的模型准确率。
本章通过一个实战案例将传统 MLOps 与大模型运维(LLMOps)衔接起来:DakkaBot,一个帮助用户查询公司文档的检索增强生成(RAG)应用。RAG 系统是企业 LLM 最常见的用例之一,它把传统信息检索的挑战与生成式 AI 的复杂性结合在一起。
通过构建与部署 DakkaBot,我们将演示如何从零设计 LLM 驱动系统。你会看到既有的 ML 平台如何提供基础,而新的架构模式如何处理 LLM 的特定需求。你在本书中学到的技能依然高度相关——你不是从头再来,而是在已有设计原则之上扩展,以支持生成式 AI 工作负载。
图 12.1 展示了包含 LLM 扩展部分的心智地图。该图分成两大部分:左侧(虚线点框)以灰色展示传统 MLOps 组件——你在前面章节构建的、对 LLM 应用仍然必不可少的基础设施;右侧(虚线框)引入第 12–13 章覆盖的 LLM 专属扩展:文档摄取流水线、用于语义搜索的向量数据库、提示管理系统、安全护栏(guardrails),以及专门的测试框架。
图 12.1 心智地图:我们聚焦于构建 RAG 应用(12)
注意这些新的 LLM 组件如何与既有基础设施集成。你的 Kubernetes(K8s)集群、持续集成/持续交付(CI/CD)流水线、以及实验追踪系统继续提供可靠性底座,而新组件负责处理 RAG、提示版本化、对抗测试等 LLM 特有需求。这不是重建——而是演进。
本章结束时,你将拥有一套完整、可观测的 RAG 系统,展示 LLM 应用的核心架构模式。下一章将聚焦如何通过系统化测试、安全措施与治理框架把该系统加固到生产可用。技术也许前沿,但工程纪律历久弥新。开始吧!
12.1 LLMOps:新挑战,熟悉原则
别误会——LLM 确实从根本上改变了我们构建 ML 应用的思维方式,但它并没有否定我们已经构建的一切。前面章节的 K8s 集群、监控流水线与 CI/CD 系统仍然不可或缺。你的 LLM 应用依然需要可靠基础设施、系统化部署与持续监控。
12.1.1 LLM 应用哪里不一样
LLM 应用打破了传统 MLOps 赖以成立的若干假设。理解这些差异是调整运维实践的关键。LLM 引入了传统模型不需要的新一层复杂性。我们在下面小节讨论这些挑战。
非确定性输出需要新的测试方法(NONDETERMINISTIC OUTPUTS REQUIRING NEW TESTING APPROACHES)
不同于传统 ML 模型对相同输入给出一致输出,LLM 天生是概率性的。由于采样策略、温度设置(控制随机性)以及模型架构等因素,同一个 prompt 在多次调用中可能产生不同回答。
这种非确定性破坏了基础测试假设。当相同输入可能对应多个合理输出时,传统断言如 assert model.predict(input) == expected_output 变得没有意义。取而代之,你需要评估框架去判断语义正确性、事实准确性以及是否遵循指令,而不是做精确字符串匹配。
理解 LLM 的采样参数(UNDERSTANDING LLM SAMPLING PARAMETERS)
LLM 并不是每一步都输出“最可能的下一个词”。相反,它会用引入受控随机性的采样策略,使回答更自然更多样——同时也更非确定。
- Temperature 控制回答的“创造性”。温度为 0 时,模型总是选择概率最高的下一个 token(大致相当于一个词或词片段),相同输入产生相同输出。更高温度(0.7–1.0)会让模型更可能选择概率较低的 token,增加多样性,但可能降低连贯性。可以把它理解为调节模型的“冒险程度”。
- Top-K sampling 在每一步把候选词表限制为概率最高的 K 个 token。
top_k=1时输出确定;top_k=40时从 40 个最可能选项中选择,在多样性与质量之间做平衡,避免选到极不可能导致回答跑偏的 token。 - Top-P(nucleus sampling) 根据概率分布动态决定候选词表大小。它不是固定数量,而是选择累计概率达到 P% 的最小 token 集合。当模型很有把握(某个 token 概率很高)时候选更少;不确定时候选更多。
虽然非确定性让测试更复杂,但它对很多 LLM 应用其实是必要的。更高温度与更丰富采样适合创意生成,你需要多样想法而不是每次同一句话。做特性头脑风暴、探索解决方案时,确定性输出只会给你一个视角。研究助理受益于不同表述带来的不同切入点。客服机器人通过轻微变化显得更自然,避免“机械重复”。关键在于让采样策略匹配用例:事实检索要求一致性,用温度 0;生成式与对话式应用则拥抱受控随机。
token 限制与上下文窗口(TOKEN LIMITS AND CONTEXT WINDOWS)
每个 LLM 都有 token 限制,约束输入与输出:
- 输入/上下文窗口——模型可处理的最大 token 数(例如 GPT-4 Turbo 为 128K,Claude 3.5 Sonnet 为 200K)
- 输出上限——模型单次回答可生成的最大 token 数(通常由
max_tokens配置)
这些限制对 RAG 系统有直接影响。如果你检索 50 个文档片段,每个 500 tokens(共 25K),再加 system prompt(2K)与用户问题(100),模型在生成前就已经消耗了 27K tokens。若上下文窗口是 32K,留给回答的只剩 5K tokens。
token 预算会迫使你做架构决策:检索多少片段?片段该多长?max_tokens 设多大?超过输入上限会导致请求失败;输出上限触顶会把回答截断,可能半句就停。策略性 token 管理需要在上下文丰富度、回答完整性与成本之间权衡。
LLM 采样参数控制输出波动(LLM SAMPLING PARAMETERS CONTROL OUTPUT VARIABILITY)
理解为什么同一 prompt 会产生不同输出,是构建可靠 RAG 系统的关键。采样参数直接决定输出的稳定性与可预测性。
在生产系统中,这些参数会成为关键决策,因为用户期望相似问题得到一致行为。下面配置选项决定生成时引入多少随机性。设置得当,才能让 chatbot 既自然又稳定;设置不当,则可能让同一问题问两次得到“离谱不一致”的答案。
清单 12.1 控制输出随机性的参数
temperature=0.0 #1
temperature=0.7 #2
temperature=1.5 #3
top_k=1 #4
top_k=40 #5
top_k=100 #6
top_p=0.1 #7
top_p=0.9 #8
top_p=1.0 #9
#1 确定性:总是选择最可能 token
#2 平衡:有一定创造性但仍保持连贯
#3 更具创造性:更随机,可能不连贯
#4 只考虑最可能的 1 个 token
#5 从最可能的 40 个 token 中选择(常见设置)
#6 候选更广,多样性更高
#7 非常收敛:只考虑最高概率 token
#8 平衡:覆盖大多数合理选项
#9 考虑所有可能 token
这些参数解释了为什么传统测试方法在 LLM 上失效:同一输入可以合理地产生不同有效输出,因此需要评估框架去衡量语义质量而不是精确匹配。
这意味着你熟悉的单元测试套路不再好用。你需要能在可变输出下评估语义相似性、事实准确性与指令遵循度的评估框架。我们在示例中会逐步引入多种测试方法。
多步骤推理链 vs 单次预测(MULTISTEP REASONING CHAINS VS. SINGLE MODEL PREDICTIONS)
传统 ML 工作流通常是线性的:数据进、预测出。而 LLM 应用往往涉及复杂编排:检索文档、合成上下文、生成回答、校验输出。一次用户查询可能触发跨多个模型与系统的几十个操作。当 12 步推理链的第 5 步出了问题,调试难度会陡增。我们将介绍工具,让你能看清每一步发生了什么:LLM 的输入(prompt + 向量库上下文)、LLM 的输出等。
提示工程成为新学科(PROMPT ENGINEERING AS A NEW DISCIPLINE)
在传统 ML 中,特征工程决定性能上限。在 LLM 里,提示工程同样关键。提示不仅是你在 ChatGPT 里输入的对话句子——它们可能是 SQL schema、JSON 规范、API 文档等结构化格式,用以提供丰富上下文引导模型理解。
系统好不好,往往差在你如何组织指令、提供示例与管理上下文。Prompt 变成一种“代码形式”,需要版本控制、测试与系统化改进——只不过它是自然语言写的,而不是 Python。
token 计费模型 vs 按算力计费(TOKEN-BASED COST MODELS VS. COMPUTE-TIME PRICING)
传统模型的计算成本更可预测:你预置基础设施并为 uptime 付费。LLM 引入可变的按用量计费:成本取决于输入与输出 token 数。一条复杂查询可能比简单查询贵 100 倍。这会让优化目标从“最大化吞吐”转向“智能资源分配与 prompt 效率”。我们会介绍成本追踪工具,帮助你识别可优化点与权衡。
12.1.2 为 LLM 扩展我们的 ML 平台
LLM 应用引入新的架构组件——向量数据库、embedding 模型、prompt 模板——它们需要与既有 ML 平台集成。我们来看哪些保持不变,哪些是新增。
传统 MLOps 仍然适用的地方(WHERE TRADITIONAL MLOPS PRACTICES STILL APPLY)
你现有 ML 平台继续负责 LLM 应用绕不开的基本功。基础设施层仍提供关键服务:LLM 应用运行在同样的容器里,使用同样的负载均衡,并受益于同样的自动扩缩容策略。无论你服务的是 scikit-learn 模型还是 RAG 流水线,你都需要可靠计算资源。
部署自动化同样重要。第 3 章的 CI/CD 流水线可以直接迁移到 LLM 应用。部署脚本需要处理新产物(向量索引、prompt 模板)以及传统模型文件,但自动化原则完全一致。我们已经看到 Docker 容器化让模型能一致部署,K8s 则管理这些容器,按需进行资源与扩缩容管理。
LLM 部署元数据:不止模型权重(LLM DEPLOYMENT METADATA: BEYOND MODEL WEIGHTS)
传统 ML 模型部署产物相对简单——权重、配置文件、也许还有预处理代码。LLM 应用需要更丰富的元数据生态,首先是 prompt 产物:
- 定义角色与行为边界的 system prompt
- 带变量替换模式的 user prompt 模板
- 用于 in-context learning 的 few-shot 示例
- chain-of-thought 推理模板
配置参数包括:
- token 限制(输入上下文窗口、最大输出 tokens)
- 采样参数(temperature、top-K、top-P)
- 模型选择(使用哪个 provider/哪个模型版本)
- 重试策略与超时设置
检索组件(RAG)包括:
- 向量数据库索引与 embeddings
- 分块(chunking)策略与元数据 schema
- 检索参数(chunk 数量、相似度阈值)
- 文档预处理流水线
评估产物包括:
- 测试 prompt 数据集与期望行为
- 评估指标(相关性、忠实度、毒性分数等)
- 人类反馈与偏好数据
- 不同 prompt 变体的 A/B 测试结果
不同于传统模型主要 version weights 与超参,LLM 系统需要对整套产物生态做版本管理。system prompt 的一次修改对行为的影响可能不亚于切换模型版本。token 限制既影响可部署能力,也影响运行成本。正因如此,才出现了针对 LLM 的专用工具(例如“面向 LLM 的 MLflow”、Langfuse 等):要追踪 “prompt_v2.3 + gpt-4 + temperature=0.7 + max_tokens=500”,需要比传统 ML 更丰富的产物管理。
实验追踪与产物版本化依然关键。正如第 4 章引入的那样,MLflow 这类 tracking server 对管理超参数、指标与产物至关重要。对 LLM 来说同样重要:你需要追踪不同 prompt 模板、不同 LLM 参数与评估结果的实验。此外,第 9 章强调了血缘(lineage)的重要性;在 LLM 场景中,清晰血缘能帮助调试:把某个输出追溯回产生它的具体数据、模型版本与 prompt。
虽然 MLflow 提供优秀的实验追踪能力,但 LLM 应用对可观测性有独特需求,适合用专用工具补足。本章我们引入 Langfuse,它提供 LLM 专属能力,如细粒度 prompt tracing、token 用量监控、以及对话级分析,用以补充 MLflow 更广义的实验追踪能力。
你的监控底座依然不可或缺——系统级指标(CPU、内存、请求延迟)对 LLM 应用同样重要。Prometheus/Grafana 继续提供基础设施健康的关键可见性。安全与访问控制框架也可自然延伸:认证、授权与网络策略同样适用于传统 ML API 与 LLM 端点。正如第 8 章提醒的,在生产中用明文默认密码是糟糕实践。
不过,LLM 也引入新的攻击面,需要特别关注:prompt injection 可通过把恶意指令嵌入用户输入来操控模型行为,可能导致模型忽略 system prompt 或泄露敏感信息;通过精心构造的训练样本进行数据投毒、绕过安全护栏的 jailbreak 尝试、以及通过外部数据源触发的间接 prompt 注入,都是独特威胁。此外,LLM 可能因记忆而无意暴露训练数据,或在安全措施下仍生成有害内容,因此需要强健的输入校验、输出过滤与对模型响应的持续监控。
LLM 应用的新组件(NEW COMPONENTS FOR LLM APPLICATIONS)
LLM 应用引入专门的基础设施需求,它们与现有平台集成——而不是替代。向量数据库与传统数据库的根本目的不同:传统数据库存结构化数据;向量数据库存高维 embedding 以支持语义搜索。在生产中,你需要自动化备份与恢复、索引版本化与回滚、相似度搜索性能监控,并与现有数据库管理实践集成。
提示管理系统把 prompt 当作关键基础设施:需要与 Git 工作流集成做版本控制、支持 prompt 实验的 A/B 测试框架、把 prompt 当作代码产物的部署流水线,以及当 prompt 变更导致性能退化时的回滚机制。
生产 LLM 应用还需要通过 LLM gateway 与缓存系统实现智能路由——而且要具备超越传统负载均衡的 LLM 专属能力。与把请求路由到“相同后端”不同,LLM gateway 需要在不同 provider(OpenAI、Anthropic、Google)之间路由,这些 provider API、价格与限流各不相同。
响应缓存是语义级而不是精确匹配:同一问题的不同表述(“退款政策是什么?” vs “怎么把钱退回来?”)可以基于 embedding 相似度命中缓存。限流也基于 token 预算而不是请求数;fallback 策略必须处理 provider 特有失败(例如上下文长度超限),并能自动切换到上下文更大的模型。
专用监控在你现有监控栈之上扩展 LLM 维度:需要 token 消耗与成本归因、回答质量指标与漂移检测、多步骤推理链的 trace 可视化、以及带自动熔断器的安全违规告警。
你的现有平台提供可靠性底座,而新的 LLM 组件处理其独特需求。你不是在重建基础设施——你是在扩展已验证系统来支持生成式 AI 工作负载。
一个集成示例(AN EXAMPLE INTEGRATION)
你的现有 K8s 集群可以同时运行传统 ML 模型与 LLM 应用。图 12.2 展示:同一套 ingress controllers、service meshes 与监控工具同时管理两类工作负载。当你需要扩展 RAG 应用时,同一个用于扩展推荐引擎的水平 pod 自动扩缩容器(HPA)也会生效。
图 12.2 传统 ML 模型与 LLM 应用在同一集群共存,并由同样的基础设施组件管理。同一个用于扩缩 XGBoost 推荐引擎的 HPA,也能扩缩你的 LangChain RAG 应用。不需要单独基础设施——只是在同一套 K8s 编排之上承载不同工作负载类型。
这种演进式方法让你在增量采用 LLM 能力的同时复用既有投入。熟悉当前平台的团队无需学习全新的运维范式,就能立即开始构建 LLM 应用。
12.1.3 LLM 应用的关键工具(Essential tools for LLM applications)
构建一个简单的 RAG 应用相对直接。但把它部署到真实客户面前,则完全是另一回事。在本节中,我们将介绍构成生产级 LLM 系统基础的关键工具。
LLM 与 Embeddings(LLMS AND EMBEDDINGS)
Embedding 是连接人类语言与数学计算的桥梁,使语义搜索成为可能。LLM 负责生成文本,而 embedding 模型则把文本转换为高维数值向量,用以捕获语义含义。
传统关键词搜索会找到包含特定词汇的文档;基于 embedding 的搜索则能找到“含义相近”的文档。一个关于“API authentication”的查询,可能匹配到讨论“user login security”或“service authorization”的文档,即便它们完全没有共同关键词。
Embedding 模型是专门训练的神经网络,用于把语义相似的文本映射到向量空间中彼此相近的位置。相似主题的文档会聚成簇,而无关内容则保持距离。
这种数学表示让你的 RAG 系统理解:即便措辞完全不同,“How do I update my API credentials?” 与 “What’s the process for key rotation?” 其实在问同一个概念。
FAISS:高效向量数据库(FAISS: AN EFFICIENT VECTOR DB)
Facebook AI Similarity Search(FAISS)充当你的语义搜索引擎——一种专门数据库,优化用于在数百万选项中快速找到相似向量。传统数据库擅长精确匹配:SELECT * WHERE user_id = 12345。但在数百万高维向量里找“与这个查询向量最相似的 10 个向量”,需要 SQL 数据库并非为之设计的专用数据结构与算法。
FAISS 对开发与中等规模生产场景表现很好,但企业应用往往会进阶到专用向量数据库,如 Pinecone、Weaviate 或 Chroma,它们提供分布式存储与计算、实时更新与删除、向量检索之外的元数据过滤、内置备份与恢复等能力。
FAISS 位于 embedding 模型与 LLM 之间:文档会先被分块(chunked),再用专用文本 embedding 模型(例如 OpenAI 的 text-embedding-3 或 Sentence Transformers)编码成向量;FAISS 对这些向量建立索引以加速检索;用户查询会被映射到同一向量空间;FAISS 找出语义最相近的文档块;检索出的块作为上下文供 LLM 生成回答。向量数据库因此成为系统的“记忆”——让它能基于语义而不是精确关键词匹配,从知识库中快速定位相关信息。
LangChain:构建 LLM 应用的框架(LANGCHAIN: A FRAMEWORK FOR BUILDING LLM APPLICATIONS)
手写 RAG 流水线意味着要处理大量集成难题,而随着系统增长,这些难题会叠加放大。每个步骤——对查询做 embedding、向量检索、格式化上下文、调用 LLM——都可能独立失败;组件之间的接口也需要小心的错误处理与数据转换。
对比一下“手写实现”与 LangChain 的声明式方式在复杂度与可维护性上的差异:手写版本需要你自行处理连接管理、重试逻辑、错误处理,以及跨多个 API 的格式转换。LangChain 把这些集成难题抽象成经过验证、可复用的组件,你无需重写集成代码即可替换与配置。LangChain 为常见 LLM 应用构建块提供标准化接口(图 12.3):
图 12.3 LangChain 的 chains 将组件串联起来,每个 chunk 的输出成为下一个 chunk 的输入。
- 文档加载器(Document loaders) ——提供统一 API,用于摄取 PDF、网页、数据库与 API 数据。
- 文本切分器(Text splitters) ——提供保留语义边界的智能分块策略。
- Embedding 模型(Embedding models) ——在 OpenAI、Gemini 与本地模型之间保持一致接口。
- 向量存储(Vector stores) ——抽象 FAISS、Pinecone、Chroma 等多种实现。
- LLMs——为 OpenAI ChatGPT、Claude、Gemini 与本地模型提供统一接口。
- Chains——为常见模式提供预置工作流(RAG、总结、问答)。
图 12.3 展示这些组件按顺序串联——每个组件输出成为下一个组件输入——从而构建出可预测、可调试、易于推理的工作流,同时又能扩展到复杂的多步骤流水线。
下面例子展示手写 RAG 与 LangChain 简化方式的差异。
清单 12.2 手写 RAG vs LangChain 声明式流水线
def manual_rag_query(query: str):
try:
query_embedding = embedding_client.embed(query)
results = vector_db.similarity_search(query_embedding, k=5)
context = "\n".join([doc.content for doc in results])
prompt = f"Context: {context}\nQuestion: {query}\nAnswer:"
response = llm_client.generate(prompt)
return validate_response(response)
except Exception as e:
return handle_error(e)
from langchain.chains import RetrievalQA
from langchain.retrievers import VectorStoreRetriever
chain = RetrievalQA.from_chain_type(
llm=llm,
retriever=vector_store.as_retriever(),
return_source_documents=True
)
result = chain.invoke({"query": query})
LangChain 的一个关键优势是组件可互换性。你可以在不重写集成代码的情况下,试验不同 embedding 模型、向量数据库或 LLM。比如你一开始用 OpenAI embedding 模型:
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
如果改变主意,直接替换即可,而无需改其他代码:
embeddings = GoogleGenerativeAIEmbeddings(model="text-embedding-004")
无论使用哪种 embedding,向量存储调用方式保持一致:
vector_store = FAISS.from_documents(documents, embeddings)
LangChain 也能自然集成 tracing 与监控工具,使多步骤推理链具备可见性:
from langchain.callbacks import LangfuseCallbackHandler
langfuse_handler = LangfuseCallbackHandler()
result = chain.invoke(
{"query": "How do I rotate API keys?"},
config={"callbacks": [langfuse_handler]}
)
LangChain 把生产最佳实践编码为可复用模式,包括:带指数退避的自动重试与错误处理、API 调用内置节流的限流机制、用于降低成本与延迟的响应缓存、实时流式输出支持、以及高吞吐场景下的异步非阻塞执行。
尽管如此,LangChain 并非总是最合适的选择。它擅长顺序执行——线性链路里每一步把结果传给下一步(retrieve → augment → generate)。
但当应用需要更复杂的工作流(条件分支、并行执行或循环模式)时,LangGraph 作为演进方向出现了。LangGraph 在 LangChain 之上引入图结构编排,支持更复杂的 agent 行为、多路径决策树与迭代精炼循环,这是顺序链难以优雅表达的。图 12.4 说明了 LangChain 与 LangGraph 的核心架构差异。
图 12.4 LangChain(左)提供顺序链,适合直接的 RAG 流水线;LangGraph(右)支持图结构工作流,包含条件分支、并行执行与迭代精炼循环。
左侧,LangChain 的顺序流程从用户查询线性流经 embedding、检索、增强与生成——非常适合每一步都可预测衔接的 RAG。右侧,LangGraph 引入决策节点与循环:意图分类器把查询路由到不同处理路径(缓存、搜索或澄清),回答在验证阶段可能进入精炼循环,验证失败则回到前面继续改进。这种图结构在构建 agentic 系统时变得必不可少:系统需要在运行时做决策、处理含糊输入,或在返回给用户前反复改进输出。
建议先用 LangChain 快速原型与实验。随着应用成熟、需求逐渐清晰,再评估这些抽象是否仍然适用,或者是否应该改用自研实现。
这个框架在探索阶段尤其亮眼:当你在测试不同模型、prompt 策略与检索方法时,LangChain 能显著提效。一旦找到有效方案,你可以选择保留抽象以便维护,也可以为最大控制力而实现定制方案。
Langfuse:开源 LLM 工程平台(LANGFUSE: AN OPEN SOURCE LLM ENGINEERING PLATFORM)
LLM 的 tracing 与监控往往需要更专门的工具,因为你想追踪的东西不同。例如在 RAG 中,你希望追踪检索器返回了哪些文档。此外,你还想监控一个查询的平均成本——它可能远高于单次 LLM 调用。你也会关注首 token 时间(time to first output token):用户按下回车后要等多久,屏幕上才开始出现内容。
但这只是 Langfuse 能力的一点预告。它还提供 prompt 管理能力,比如对 prompt 做版本化并发布新 prompt。
Promptfoo:开源 LLM 安全(PROMPTFOO: OPEN SOURCE LLM SECURITY)
有一件事你绝不能省:安全。无论你在构建公司内部聊天机器人,还是把产品部署到互联网,你都必须确保机器人不会说出你不希望它说的话,也不会做出你不希望它做的事。在 LLM 非确定性的背景下,这本来就足够难了。
好在并非无解。像 Promptfoo 这样的工具会尝试找出不仅存在于模型本身、也存在于 LLM 应用中的漏洞。在第 13 章你会亲眼看到 Promptfoo 如何“变出”各种创意 jailbreak prompts——它们总能让我们震惊(有时甚至让人后背发凉)。
12.2 构建 DataKrypt 的 DakkaBot:一个简单的 RAG 架构
你在 DataKrypt 工作——一家专注于企业级数据安全与加密解决方案的公司。随着公司从 50 人的创业团队扩张为 200+ 的工程组织,复杂度在技术与组织层面都显著上升。新工程师在理解 DataKrypt 的专有安全协议、复杂部署流程以及繁琐合规要求时,学习曲线非常陡峭。
幸运的是,DataKrypt 一直坚信:优秀代码必须配套优秀文档。公司维护了全面的技术指南、API 文档、运行手册(runbooks)以及架构决策记录(architectural decision records)。但信息量一旦变得过于庞大,新人就很难快速找到相关答案,反而被“信息海洋”淹没。
为了简化入职培训并帮助新工程师更快上手,你——作为 DataKrypt 最大的 AI 传播者——获得了一个令人兴奋的机会:打造公司史上第一个开发者助手 DakkaBot。
12.2.1 你将构建什么
我们来学习如何为内部使用构建一个企业级聊天机器人,把端到端的全流程都搭起来。如图 12.5 所示,我们会先把多种格式的文档摄取进向量数据库,选择一个 LLM 与 embedding 模型,并搭建一个编排框架把所有组件串起来。然后,我们再加一个用户友好的界面,让同事们可以马上开始试用与实验。
图 12.5 构建期(上)将文档摄取进向量数据库并配置 LLM。查询期(中)通过 embedding、检索、增强与生成处理用户问题。实现只需要三行 LangChain 代码。
但初版只是开始。LLM 天生是非确定性的——同一个问题问两次,可能得到完全不同的答案。随着本章推进,我们会加入全面的可观测性,让你能在 RAG 流水线的每个阶段都清楚看到应用在做什么。
监控不只是为了理解行为,也为了控制成本。商用 LLM 基于 token 计费,生产负载很容易变得昂贵。我们会展示实时追踪花费的工具,以及在不牺牲回答质量的前提下降本的技巧。本章结束时,你将拥有一套生产可用的 RAG 系统,展示传统 ML 工程实践如何扩展以支撑 LLM 应用。
12.2.2 超越单次 API 调用:为可组合性而设计
单次 LLM 调用适用于简单任务——翻译、摘要或基础问答。但企业应用需要更复杂的推理能力,单次 API 调用无法胜任。你需要能拆解复杂问题、在多步骤中保持上下文、并在某个组件失败时优雅恢复的系统。
单次 LLM 调用在复杂任务上的局限(THE LIMITATIONS OF SINGLE LLM CALLS FOR COMPLEX TASKS)
- 上下文窗口约束是第一个主要限制。即便最大的模型也有有限上下文窗口。GPT-4 可处理约 128K tokens,但公司的文档可能达到数百万 tokens。你不能把所有内容一股脑塞进一个 prompt,然后指望得到高质量结果。
- 截至目前,模型的上下文窗口大小是固定的——这是 LLM 一次能处理的最大文本量(以 tokens 计),包含输入与输出。你可以把它理解为模型的工作记忆。ChatGPT 3.5 刚出来时窗口为 4,192 tokens;现在一些 Gemini 模型有 100 万 tokens(甚至有版本可到 200 万 tokens)。
- 推理质量退化会在 prompt 变长、变复杂时出现。LLM 很难保持聚焦。一个同时要求“检索、分析、综合、格式化”的大 prompt,通常不如把职责拆分、每一步聚焦单一目标的流水线。
- 错误传播会制造“全有或全无”的局面。单个大 prompt 一旦某处被误解,整个响应就不可靠。多步骤系统可以隔离故障,并实现有针对性的恢复策略。
- 成本低效来自对相似查询反复处理巨大上下文,浪费 tokens。可组合系统可以缓存中间结果,并在多次用户请求间复用计算。
为什么 RAG 能解决这些问题(WHY RAG SOLVES THESE PROBLEMS)
RAG 通过把“信息检索”与“回答生成”分离,解决这些限制,使 LLM 应用更复杂且更具成本效率。
- 训练截止带来的限制:语言模型在训练截止日期后就“冻结”了。RAG 让你无需重训整个模型,也能访问训练后产生的新信息。比如上个月更新的安全流程不在 GPT-4 训练数据里,但 RAG 可以让它即时可用。
- 选择性检索:RAG 不会把所有内容塞进上下文,而只检索回答特定问题所需的内容,提升聚焦、减少噪声。比如不加载整本 500 页员工手册,而是通过将查询向量与已索引文档块向量对比,找出与休假政策相关的三段内容。
- 动态/实时数据支持:RAG 能访问不断变化的实时数据,例如股价、新闻流、用户画像与库存等,让知识库保持最新而无需重训。
- 私有数据访问:商用 LLM 不会也不该训练你的专有文档、数据库与内部系统;但通过 RAG,你仍然能利用 LLM 的自然语言理解能力来理解这些私有数据。
- 成本效率:反复处理海量上下文非常昂贵;RAG 只提供相关信息给 LLM,而不是每次都喂完整文档集合,从而显著降本。
- 准确性与可落地性(grounding) :RAG 对抗幻觉(hallucination,即生成看似合理但错误的信息)。它提供来源归因,把回答锚定在真实文档中,显著减少“编造”,并支持事实核验。
12.2.3 Google 的 Gemini LLM 与 embeddings
在这个示例中,我们将使用 Google 的 Gemini 模型同时完成文本生成与 embeddings。Gemini 对企业 RAG 有一些吸引力:有竞争力的定价、多语言表现强、并且提供能无缝协作的一体化 embedding 模型。这里不是背书,但 Gemini 的免费层非常适合做实验。
我们用 text-embedding-004 把文档与查询转换为向量表示;用 gemini-2.5-flash 生成回答,它在保证质量的同时针对速度与成本做了优化。
开始使用 Google Gemini(GETTING STARTED WITH GOOGLE GEMINI)
初始步骤包括:
- API 配置——进入 Google AI Studio(aistudio.google.com/)创建 API key。
- 模型访问——确保你能访问 embedding 与生成模型。
- 限流/配额——理解配额限制,便于做生产规划。
图 12.6 展示 Google AI Studio 界面:你可以生成 API keys,并在接入应用前先交互式测试模型。
图 12.6 Google AI Studio:API key 生成界面,以及 Gemini 集成的 quickstart 指南
生产考量(PRODUCTION CONSIDERATIONS)
- 限流(rate limiting)考量:即便 Gemini 的限流看起来很宽松,你仍应实现指数退避(exponential backoff)以增强鲁棒性:当遇到临时 throttling 时能优雅处理,自动重试并逐步增加等待时间,即便在峰值使用或意外的限流命中时也保持稳定。
- 在共享托管 API 服务上,限流并不总是由你的使用模式触发;平台级整体需求也可能同时影响所有客户。因此健壮的重试逻辑是“必需品”,不是“可选项”。
- 监控要求包括追踪 embedding 与生成两类调用的 token 用量,并在高可用需求下考虑把 OpenAI 或 Claude 作为备选 fallback。
Gemini 在性能、成本效益与企业特性上的组合,使其成为像 DakkaBot 这样的内部 RAG 应用的优秀选择。AI Studio 的一体化工具也简化了开发与测试工作流。
12.2.4 检索组件(The retrieval component)
检索组件是 RAG 系统的基础——它决定 LLM 能看到什么信息,并直接影响回答质量。不同于传统数据库按精确关键词匹配,现代检索会结合多种技术来找到与查询最相关的信息。
从 embedding 的语义搜索开始(STARTING WITH SEMANTIC SEARCH USING EMBEDDINGS)
向量数据库存储文档的高维数值表示,这些表示由 embedding 模型生成,用于捕获语义含义。在我们的 DakkaBot 实现中,我们选择 FAISS,因为它简单且性能好。每个文档块会被转换为 768 或 1,024 维向量,表示其语义内容。
创建一个自定义 embedding wrapper 有两个关键目的:它标准化了应用与 Google API 的接口;并允许你做任务级优化以提升检索质量。wrapper 模式把 API 依赖隔离开,让你将来切换 embedding provider 时无需改动应用逻辑。
实现中最重要的一点是区分“文档 embedding”与“查询 embedding”。Google 的 embedding 模型可以针对不同任务优化向量表示——索引用于存储的内容,与用于搜索的用户查询,优化方向不同。这种非对称方式(asymmetric)通常能比“对文档和查询都用同一类通用 embedding”带来 10%–20% 的检索准确率提升。下面的 wrapper 展示了语义搜索的核心模式:为不同 embedding 任务提供统一接口(见清单 12.3)。
清单 12.3 用于文档索引与查询的 Embedding API wrapper
class GeminiEmbeddings(Embeddings):
def __init__(self):
self.client = genai.Client() #1
def embed_documents(self,
texts: List[str])
-> List[List[float]]: #2
embeddings = []
for text in tqdm(texts, desc="Embedding documents"):
result = self.client.models.embed_content(
model="text-embedding-004", #3
contents=text,
config=types.EmbedContentConfig(
task_type="RETRIEVAL_DOCUMENT" #4
)
)
embeddings.append(
result.embeddings[0].values) #5
return embeddings
def embed_query(self, text: str) -> List[float]:
result = self.client.models.embed_content(
model="text-embedding-004",
contents=text,
config=types.EmbedContentConfig(
task_type="RETRIEVAL_QUERY" #6
)
)
return result.embeddings[0].values
#1 初始化 Gemini API client 用于生成 embeddings
#2 方法签名:输入文本列表,输出二维 float embeddings 数组
#3 使用 Google 的 text-embedding-004 模型
#4 针对向量库文档存储优化 embeddings
#5 从 Gemini 的响应格式中取出数值向量
#6 对搜索查询使用 query 优化的 embedding 配置
GeminiEmbeddings 类展示了语义搜索实现的核心模式。初始化通过 client SDK 建立到 Google Gemini API 的连接。embed_documents 处理一组文本并返回它们的向量表示(float 数组)。
使用 tqdm 做进度显示在 embedding 过程中非常重要,因为对大规模文档集合生成 embedding 可能耗时很久。每个文档逐个处理,也能避免大批量带来的内存问题。
模型选择使用 text-embedding-004,它提供针对检索任务优化的 768 维向量。任务级配置告诉模型为“文档存储”而不是“查询处理”优化 embedding,从而提升检索质量。这是模型相关的特性,因此务必查看对应文档。
向量提取部分从 Gemini 的响应格式里拿到数值向量并写入结果列表。方法最终返回将被索引到向量数据库里的整套文档 embeddings。
注意 task_type 的区分:索引用 RETRIEVAL_DOCUMENT,查询用 RETRIEVAL_QUERY。这种非对称方式让 embedding 模型能分别优化“存储表示”和“搜索表示”。
检索质量因素(RETRIEVAL QUALITY FACTORS)
RAG 系统对检索高度敏感——如果检索不到相关文档,无论 LLM 再强,后续推理都会不可靠。影响检索效果的因素包括:
- **分块策略(chunking)**影响检索粒度。块更小匹配更精准但可能缺少上下文;块更大上下文更完整但会引入噪声。
- embedding 模型质量与训练领域相关。用技术文档训练的 embedding 更懂企业内容,而通用 embedding 可能不够贴合。
- 相似度度量不同。FAISS 使用距离度量判断向量“有多近”,并支持 L2、cosine、inner product 等。多数文本应用 cosine 相似度表现很好,因为它关注语义方向而非幅值。
- 检索参数需要平衡。返回文档数(k)与相似度阈值需要在相关性与覆盖度之间取舍:太少会漏掉关键上下文;太多会引入噪声。
生产检索考量(PRODUCTION RETRIEVAL CONSIDERATIONS)
检索组件的可靠性直接影响用户信任——系统找不到相关信息或返回过时内容,用户很快就会察觉。生产系统中应考虑:用文件 hash 或时间戳做变更检测,从而只对变更内容做选择性更新而不是全量重建;维护版本化索引以便当更新导致质量下降时快速回滚;对大规模系统使用蓝绿部署,在更新知识库时让新旧索引并存以降低停机风险。例如,如果 DakkaBot 的 API 文档在每次生产部署后都会自动重建,你可以触发仅对变更文档做重新摄取,同时保持其他知识库内容不动。
构建文档索引(BUILDING THE DOCUMENT INDEX)
我们按步骤实现检索组件:构建一个完整索引流水线,把公司文档变成可搜索的向量数据库。
Step 1:文档加载(DOCUMENT LOADING)
任何 RAG 系统的基础,是把现有文档转换为可处理、可索引的格式。对 DakkaBot 来说,我们需要系统性发现并加载 DataKrypt 文档目录中的所有 Markdown 文件,它们可能散落在多个子目录里,包括 API 指南、安全流程、部署 runbooks 与架构决策记录。
LangChain 的 DirectoryLoader 可以处理这些复杂性(见清单 12.4):它会递归遍历目录结构并自动识别文件类型。加载器会把每个文件转换为标准化的 Document 对象,保留内容与元数据(例如文件路径、修改时间),这些元数据在后续 RAG 流水线做来源归因时非常关键。
清单 12.4 递归查找并加载 Markdown 文件的 Loader
from langchain_community.document_loaders import ( #1
DirectoryLoader, TextLoader) #2
from langchain.schema import Document
def load_documents(data_dir: str) -> List[Document]: #2
"""Load all Markdown documents from directory"""
loader = DirectoryLoader(
data_dir, #3
glob="**/*.md", #4
loader_cls=TextLoader, #5
loader_kwargs={"encoding": "utf-8"} #6
)
documents = loader.load() #7
return documents
#1 LangChain 的 document loaders 自动处理多种文件格式
#2 返回包含内容与元数据的 Document 列表
#3 文档目录路径
#4 递归 glob:查找子目录中所有 .md
#5 TextLoader 负责读取纯文本与 Markdown
#6 显式 UTF-8 编码,避免字符编码错误
#7 将所有匹配文件加载为 LangChain 的 document 格式
Step 2:智能分块(INTELLIGENT DOCUMENT CHUNKING)
原始文档往往太长,不利于有效检索。我们需要把它拆成更小、语义连贯的 chunks。分块策略对检索质量至关重要:我们希望在自然边界处分割,同时在 chunk 边界维持上下文。下面的 text splitter 会在保持语义结构的同时,进行有重叠的智能切分(见清单 12.5)。
清单 12.5 带 overlap 的分块 Text splitter
from langchain.text_splitter import RecursiveCharacterTextSplitter
def chunk_documents( #1
documents: List[Document]) -> List[Document]: #1
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, #2
chunk_overlap=200, #3
separators=["\n## ", "\n### ", #4
"\n\n", "\n", " ", ""] #4
)
chunks = text_splitter.split_documents(documents) #5
return chunks
#1 输入完整文档,输出更小、更可控的 chunks
#2 每个 chunk 目标 1,000 字符,以获得更好的 embedding 性能
#3 200 字符 overlap 维持 chunk 边界上下文
#4 分层 separators 保留结构(标题 → 段落 → 句子)
#5 执行切分并保留文档元数据
RecursiveCharacterTextSplitter 会优先在自然边界处分割:先标题,再段落,再句子。200 的 overlap 确保边界处上下文不丢失,对保持检索信息连贯性非常关键。
Step 3:创建向量索引(CREATING THE VECTOR INDEX)
语义搜索的“魔法”从这里开始。我们要把可读文本 chunks 转成高维数值向量,以捕获其语义含义,然后把向量组织成可检索的索引结构,优化相似度查询。
这个过程包含两步高开销计算:第一,用 Google 的 text-embedding-004 为每个 chunk 生成向量表示;第二,FAISS 构建专用索引结构,使相似度检索足够快。索引步骤之所以关键,是因为朴素的向量相似检索扩展性很差——若对百万级向量逐一比较,交互式应用会慢到不可用。
我们的 GeminiEmbeddings 类负责与 Google embedding 服务通信;FAISS 负责优化数学运算,从而支撑 DakkaBot 的快速语义检索。下面清单展示从文本 chunks 到可搜索向量库的完整转换。
清单 12.6 基于文档 chunks 构建 FAISS 向量索引
from langchain_community.vectorstores import FAISS
def create_faiss_index(chunks: List[Document]) -> FAISS: #1
embeddings = GeminiEmbeddings() #2
vectorstore = FAISS.from_documents( #3
chunks, embeddings) #3
return vectorstore #4
#1 将文本 chunks 转为可搜索的向量数据库
#2 实例化自定义 Gemini embedding wrapper
#3 FAISS 基于 embeddings 构建高效相似度检索索引
#4 返回可直接使用的 vector store
Step 4:持久化存储(PERSISTENT STORAGE)
构建向量索引既耗时又昂贵——对成千上万文档生成 embedding 可能需要数小时,并产生可观的 API 费用。在生产环境里,你不可能每次应用重启、扩容新实例或故障恢复时都从头重建索引。
FAISS 提供内置序列化能力:可以把索引结构与关联元数据持久化到磁盘。这样应用启动时可以直接加载预计算索引,而不是重新生成,从而显著加速启动。持久化也支持版本化策略:你可以维护多个索引版本用于回滚或对不同文档集合做 A/B 测试。
不过,加载序列化索引要求使用与创建索引时完全相同的 embedding 配置——维度、归一化方式或模型版本任一不一致都会失败。allow_dangerous_deserialization 标志承认加载 pickle 的 Python 对象在不可信环境中有安全风险,但这是 FAISS 当前序列化格式所需。下面函数涵盖向量索引持久化与恢复的完整生命周期(见清单 12.7)。
清单 12.7 FAISS 向量索引的保存/加载与持久化
def save_index(vectorstore: FAISS, #1
index_path: str = "./DATA/faiss_index"): #1
Path(index_path).parent.mkdir( #2
parents=True, exist_ok=True) #2
vectorstore.save_local(index_path) #3
def load_index( #4
index_path: str = "./DATA/faiss_index") -> FAISS: #4
embeddings = GeminiEmbeddings() #5
vectorstore = FAISS.load_local(
index_path,
embeddings,
allow_dangerous_deserialization=True #6
)
return vectorstore
#1 将向量索引持久化到磁盘,便于跨会话复用
#2 若目录不存在则创建
#3 FAISS 将向量与元数据一起保存为本地文件
#4 从保存文件重建 vector store
#5 加载/保存必须使用相同 embedding 类
#6 加载 pickle 化的 FAISS 索引所需标志(安全注意事项)
Step 5:完整索引流水线(COMPLETE INDEXING PIPELINE)
最后,把所有步骤串起来,形成从原始文档到可搜索知识库的完整工作流。如下所示。
清单 12.8 从文档加载到向量索引的完整流水线
def main():
data_dir = "./DATA" #1
documents = load_documents(data_dir) #2
chunks = chunk_documents(documents) #3
vectorstore = create_faiss_index(chunks) #4
save_index(vectorstore) #5
test_query = "How do I rotate encryption keys?" #6
results = vectorstore.similarity_search( #7
test_query, k=3) #7
logger.info(f"\nTest query: '{test_query}'")
logger.info(f"Found {len(results)} results:")
for i, result in enumerate(results, 1):
logger.info( #8
f"{i}. {result.page_content[:100]}...") #8
#1 指向 DataKrypt 文档目录
#2 加载所有 Markdown 文件为 Document 对象
#3 把文档切成语义连贯的 chunks
#4 将 chunks 向量化并构建可检索索引
#5 将索引持久化,供生产使用
#6 用一个样例查询验证检索功能
#7 语义搜索返回最相关的三个 chunks
#8 打印每条结果前 100 字符用于快速校验
这条流水线把静态文档变成动态、可搜索的知识库。GeminiEmbeddings 通过 LangChain 接口封装 Google embedding API,并对文档(RETRIEVAL_DOCUMENT)与查询(RETRIEVAL_QUERY)使用不同 task_type,这个优化帮助模型区分“知识库内容”和“用户问题”,通常可把检索准确率提升 10%–20%。
末尾的小测试展示了语义搜索的效果:用 “How do I rotate encryption keys?” 查询时,系统会找到与密钥管理、安全流程、凭证更新相关的文档,即便它们使用了不同术语。
这条索引流水线构成 DakkaBot 知识检索系统的基础。索引一旦构建完成,就能在整个文档语料上提供快速语义检索,为后续的智能回答生成铺平道路。
12.2.5 增强组件(The augmentation component)
在检索系统就绪后,我们需要把原始文档 chunks 转换成 LLM 可理解的连贯上下文。增强(augmentation)步骤会把检索到的 chunks 与用户的查询合并成一个 prompt。比如用户问:“What’s our vacation policy?”,系统检索到三段相关手册内容,增强就会构造出这样的 prompt:“Based on these policy sections: [chunk 1], [chunk 2], [chunk 3], answer: What’s our vacation policy?”
从检索到上下文组装(FROM RETRIEVAL TO CONTEXT ASSEMBLY)
增强组件把原始文档 chunks 转换成结构化上下文,让 LLM 能更有效地处理。这一步在语义搜索结果与连贯 prompt 构造之间搭桥,确保检索到的信息被恰当地格式化、去重并组织,从而最大化 LLM 的理解效果。该过程包括:加载预构建的向量索引、建立一致的 embedding 操作,以及创建标准化的 retriever 接口,用简洁 API 调用把相似度搜索的复杂性隐藏起来。
重新连接预构建向量索引时,必须特别注意一致性与兼容性:用于加载的 embedding 模型必须与用于建索引的模型完全一致——任何维度、归一化方式或模型版本的差异都可能导致失败。
这种加载模式为 RAG 流水线后续所有检索操作奠定了基础。allow_dangerous_deserialization 标志承认一个安全上的取舍:FAISS 的序列化格式使用 Python pickling,如果文件被篡改,可能执行任意代码。在生产环境中,务必安全存储索引文件,并考虑加入完整性校验。下面代码展示如何重新连接持久化向量索引,并为查询处理做好准备。
清单 12.9 持久化 FAISS 索引与 LangChain retriever
from langchain_community.vectorstores import FAISS
from index import GeminiEmbeddings
embeddings = GeminiEmbeddings() #1
vector_store = FAISS.load_local(
"DATA/faiss_index",
embeddings=embeddings,
allow_dangerous_deserialization=True #2
)
retriever = vector_store.as_retriever() #3
#1 检索阶段必须使用与索引阶段相同的 embedding 类
#2 加载我们在前一步构建的持久化向量索引
#3 将 vector store 转换为 LangChain 的 retriever 接口
查询处理与文档检索(QUERY PROCESSING AND DOCUMENT RETRIEVAL)
检索步骤能找到相关文档,但还没有把它们变成 LLM 可用的形式。下面这个基础示例展示了 RAG 查询处理的基本模式。
清单 12.10 通过检索与上下文构造进行 RAG 查询处理
query = "How do I rotate encryption keys?" #1
docs = retriever.invoke(query) #2
context = "\n\n".join( #3
[doc.page_content for doc in docs]) #3
#1 用户的自然语言问题
#2 检索语义最相近的文档 chunks
#3 通过简单拼接生成基础的上下文字符串
这种基础增强方式适用于简单场景,但生产系统需要更复杂的上下文组装策略。最后一步的拼接代表最小可用的增强(MVP augmentation):把多个文档 chunk 合并为一个 LLM 可处理的上下文字符串。
12.2.6 生成组件(The generation component)
生成组件把你精心组装的上下文转换为面向用户的回答。在这里,提示工程与实际系统设计发生交汇:需要在保证一致性的同时,在回答质量、速度与成本之间取得平衡,适配多样化查询。
为一致性响应设置 LLM(LLM CONFIGURATION FOR CONSISTENT RESPONSES)
下面的配置会创建一个适用于生产 RAG 应用的 Gemini LLM 实例,重点是“一致性与可靠性”。把温度设为 0 会通过总是选择概率最高的 token 大幅降低随机性,使输出更一致、更可预测——尽管由于实现细节仍非绝对确定。但这种“近似确定性”已经足够用于可靠测试、调试与建立用户信任。
gemini-2.5-flash 在回答质量与速度之间取得良好平衡,非常适合用户期望快速反馈的交互式应用。convert_system_message_to_human 处理 Gemini 特有的消息格式要求,确保与 LangChain 的对话抽象无缝集成。下列配置在生产 RAG 场景里兼顾一致性与性能。
清单 12.11 Gemini 零温度配置
llm = ChatGoogleGenerativeAI(
model="gemini-2.5-flash", #1
temperature=0., #2
google_api_key=os.environ["GEMINI_API_KEY"], #3
convert_system_message_to_human=True #4
)
#1 面向快速响应优化的 Gemini 模型
#2 零温度确保确定性、更一致的输出
#3 通过环境变量完成 API 认证
#4 Gemini 特有配置:确保消息格式兼容
Prompt 构造与响应生成(PROMPT CONSTRUCTION AND RESPONSE GENERATION)
最后一步是把所有内容组合成一个结构化 prompt:通过清晰的上下文边界、明确指令与规定的回答格式,引导 LLM 产出有帮助的回复。基于模板的方法能确保 LLM 理解自己作为 DataKrypt 助手的角色,把回答锚定在提供的文档上而不是泛化常识,并在不同查询上保持输出质量一致。
带标签的结构(Context、Question、Answer)有助于 LLM 区分不同信息类型,并通过清晰区分“检索事实”和“用户问题”来减少幻觉。下面这个简单 prompt 模板展示了 RAG 生成回答的基本模式。
清单 12.12 将上下文与查询合并的 prompt 模板
prompt = f"""
Based on the following DataKrypt documentation, answer the question:
Context: {context} #1
Question: {query} #2
Answer:""" #3
response = llm.invoke(prompt) #4
print(response.content) #5
#1 将检索到的文档作为权威信息来源
#2 复述用户问题以增强清晰度
#3 清晰的指令格式引导 LLM 生成回答
#4 将完整 prompt 发送给 LLM 处理
#5 从 LLM 响应对象中提取文本内容
完整的 RAG 流程(THE COMPLETE RAG FLOW)
这个最小示例展示了 RAG 的核心模式:理解查询(用户询问 key rotation);语义检索(向量检索找到相关安全文档);上下文组装(将检索 chunks 合并为连贯上下文);提示工程(结构化模板约束并引导 LLM 行为);以及响应生成(LLM 输出有依据、相关的回答)。
尽管这段 20 行左右的实现能覆盖基础 RAG 功能,但生产系统还需要更多层:错误处理、上下文优化、响应校验以及用户体验打磨。不过,这个例子的简洁也揭示了 RAG 的根本力量:把 LLM 的广泛知识与文档中具体且最新的信息结合起来。
12.3 给 DakkaBot 加上 UI
一个没有 UI 的 RAG 系统,就像一台强劲引擎却没有方向盘——技术上很厉害,但实际上无法使用。即便你可以通过简单 API 暴露 DakkaBot,你的同事仍需要一个直观的聊天界面,让“查询公司文档”像“问同事求助”一样自然。
Chainlit 正好提供了这一点:一个专为构建对话式 AI 界面设计的 Python 框架。不同于通用 Web 框架(往往需要大量前端开发),Chainlit 聚焦于 LLM 应用最需要的能力——流式输出、对话历史、来源文档展示,以及用户反馈收集。只需几行 Python 代码,你就能把 RAG 流水线变成一个精致的聊天应用:对任何用过 ChatGPT 或 Claude 的人来说都很熟悉,但它完全运行在你的基础设施上、使用你公司的数据(图 12.7)。
图 12.7 成功启动:无需任何前端开发即可得到的界面
安装 Chainlit(INSTALLING CHAINLIT)
Chainlit 会自动处理整个前端——不需要 React、JavaScript 或 CSS。框架开箱即提供流式聊天界面、文件上传能力与会话管理。现在我们安装 Chainlit,并把它加入项目依赖:
% pip install chainlit
抽取 DakkaBotCore 以便复用(EXTRACTING DAKKABOTCORE FOR REUSABILITY)
在构建 UI 之前,我们需要把最小化的 RAG 示例重构成一个可复用类。这样做能让同一套核心逻辑服务于不同接口——Web UI、API endpoints 或测试框架。这种架构模式称为“关注点分离”(separation of concerns),它带来清晰可测、更易维护与更灵活的部署方式。下面这个类(清单 12.13)把所有 RAG 功能封装进一个生产就绪组件,可被多种接口复用。
清单 12.13 DakkaBotCore:异步初始化 RAG 组件
class DakkaBotCore: #1
def __init__(self):
self.retriever = None #2
self.llm = None
self.vector_store = None
self.initialized = False #3
async def initialize(self): #4
try:
embeddings = GeminiEmbeddings() #5
self.vector_store = FAISS.load_local(
"DATA/faiss_index",
embeddings=embeddings,
allow_dangerous_deserialization=True
)
self.retriever = self.vector_store.as_retriever(
search_kwargs={"k": 5} #6
)
self.llm = ChatGoogleGenerativeAI(
model="gemini-2.5-flash", #7
temperature=0.0,
google_api_key=os.environ["GEMINI_API_KEY"],
convert_system_message_to_human=True
)
self.initialized = True
except Exception as e:
raise RuntimeError(f"Failed to initialize DakkaBot: {str(e)}")
#1 将所有 RAG 功能封装为可复用类
#2 组件属性先置为 None,支持惰性加载
#3 追踪初始化状态,防止未就绪即被使用
#4 异步初始化支持 Web 应用中的非阻塞启动
#5 索引与检索必须使用同一个 embedding 类
#6 检索 5 篇文档(而非默认 3 篇)以获得更丰富上下文
#7 面向交互式聊天优化的快速 Gemini 模型
DakkaBotCore 把所有 RAG 功能封装进一个内聚、可复用的组件,可在不同上下文中实例化。这样同一套逻辑就能驱动 Chainlit 聊天界面、FastAPI Web 服务,或批处理系统,而无需复制代码。该类管理 LLM 应用复杂的生命周期:多个组件(embeddings、向量库、语言模型)必须按正确顺序初始化,并带有恰当的错误处理。
惰性初始化模式反映了 LLM 应用启动的现实:加载向量索引与初始化模型连接都需要时间,而且可能以多种方式失败。把对象创建与资源初始化分离开,让应用能快速创建 DakkaBotCore 实例,并在合适时机异步执行昂贵初始化。
initialized 标志提供了清晰的状态指示,避免系统未准备好就被调用。
异步初始化方法也承认现代应用需要非阻塞启动序列。这在容器化部署中尤其重要,因为启动时间会影响整体可用性。
组件配置体现了生产可用的默认值:索引与检索使用同一 embedding 类以确保一致性;检索 5 篇文档以获得更丰富上下文;选择适合交互式响应的快速 Gemini 模型。这次重构把简单脚本升级为专业组件:既能随应用需求增长,又保持干净接口与可测试架构。
带元数据的增强查询处理(ENHANCED QUERY PROCESSING WITH METADATA)
核心处理方法现在会返回更丰富的元数据,UI 可以用它来展示来源并处理错误。这是从简单 RAG 示例到生产就绪应用的进化:加入了全面错误处理、来源归因与调试能力。增强后的 query processor 会返回结构化数据,从而支持高级 UI 功能与更完善的错误处理,如下所示。
清单 12.14 返回结构化结果、来源与错误信息
async def process_query(self, #1
query: str) -> Dict[str, Any]: #1
if not self.initialized: #2
return {
"response": None,
"sources": [],
"doc_count": 0,
"error": "DakkaBot not initialized"
}
try:
docs = self.retriever.invoke(query) #3
context = "\n\n".join([
f"**Document {i + 1}:**\n{ #4
doc.page_content}" #4
for i, doc in enumerate(docs)
])
prompt = f"""You are DakkaBot, DataKrypt's
expert developer assistant.
Based on the provided DataKrypt documentation,
answer the user's question accurately and helpfully.
**Documentation Context:**
{context}
**User Question:** {query}
**Instructions:**
- Provide accurate, detailed answers based on the documentation
- Include specific steps, code examples, or
configurations when relevant
- If the question requires multiple services or
procedures, explain the complete workflow
- Use proper formatting for code blocks and lists
- If you're not sure about something, say so rather than guessing
- Stay focused on DataKrypt-specific information
**Answer:**""" #5
response = self.llm.invoke(
prompt,
)
sources = []
for i, doc in enumerate(docs, 1):
source_info = {
"index": i,
"source": doc.metadata.get('source', #6
'Unknown'), #6
"content_preview": doc.page_content[:150] + "..." if len(
doc.page_content) > 150 else doc.page_content,
"full_content": doc.page_content #7
}
sources.append(source_info)
return {
"response": response.content,
"sources": sources,
"doc_count": len(docs),
"error": None
}
except Exception as e:
return {
"response": "Sorry! Please try again!",
"sources": [],
"doc_count": 0,
"error": str(e)
}
#1 返回结构化数据(而非仅文本),便于 UI 集成
#2 友好失败:避免 Web 应用崩溃
#3 检索步骤保持不变,但现在有了更好的可观测与封装
#4 结构化上下文格式提升 LLM 理解
#5 增强版 system prompt 给出更清晰的行为边界
#6 为 UI 展示引用信息准备来源文件元数据
#7 也提供全文,便于深入查看来源
process_query 不再只返回一段文本,而是返回一个字典,包含:响应、来源文档、文档数量与错误信息。结构化返回使 UI 能展示引用、显示检索统计,并优雅处理不同失败模式。
检索与上下文组装步骤在功能上与最小示例类似,但现在加入了结构化格式,能提升 LLM 理解。通过给文档编号并使用一致的 Markdown 格式,我们帮助模型理解上下文结构,从而在回答中做更好的来源归因。
system prompt 值得特别关注——你在这里定义 LLM 的角色、人格与行为边界。除了设定回答质量与格式预期,system prompt 还是你实现以下目标的主要工具:
- 在不同查询与用户会话间标准化输出
- 通过明确系统能做/不能做什么来减少常见误解
- 通过指示模型永不泄露系统指令或内部上下文来防止数据泄露
- 建立系统 persona:是助理、领域专家还是轻松对话伙伴
- 设置护栏:限制范围、处理敏感话题、管理超出领域的问题
system prompt 本质上是你 LLM 应用的“人格芯片”,也是你最重要的提示工程工作之一。我们增强版 system prompt 给出比原始简单模板更具体的行为准则:明确对质量、格式与范围的期望,同时落实上述保护措施。
来源元数据准备是生产级增强:每条检索文档都被打包为包含来源文件信息、用于快速浏览的内容预览,以及用于深入查验的全文。这让 UI 能清楚告诉用户“哪些文档支撑了回答”,从而建立信任并支持验证。错误处理保证即便检索或生成失败,系统也会返回结构化信息,UI 可以据此给出有帮助反馈,而不是泛泛的报错。
这种架构模式——在主要结果之外返回丰富元数据——对于构建“可被用户信任并可验证、且能被开发者调试”的 LLM 应用至关重要。结构化响应也为高级 UI 能力铺路,比如来源高亮、置信指示,以及逐步披露支撑信息。
为现代聊天体验提供流式输出(STREAMING SUPPORT FOR MODERN CHAT EXPERIENCE)
现代用户期待像 ChatGPT 一样的流式响应。process_query_stream 方法支持 token-by-token 展示,把体验从“等待完整回答”变成“实时看到答案逐步生成”。这种方式显著提升感知性能与用户参与度,让交互更像对话、更具响应性。下面的流式实现提供实时 token 传输,并在生成全程提供完整状态更新。
清单 12.15 流式查询处理:实时更新
async def process_query_stream(self, #1
query: str) #1
-> AsyncGenerator[Dict[str, Any], None]: #1
try:
docs = self.retriever.invoke(query)
sources = [...] # Source preparation code
yield {
"type": "sources", (C)#2
"content": None,
"sources": sources,
"doc_count": len(docs)
}
context = "\n\n".join([...])
prompt = f"""..."""
async for chunk in self.llm.astream( #3
prompt,
config={"callbacks": [self.langfuse_handler]}
):
if chunk.content:
yield {
"type": "token", #4
"content": chunk.content,
"sources": sources,
"doc_count": len(docs)
}
yield {
"type": "done", #5
"content": None,
"sources": sources,
"doc_count": len(docs)
}
except Exception as e:
yield {
"type": "error", #6
"content": str(e),
"sources": [],
"doc_count": 0
}
#1 AsyncGenerator 支持把 token 实时流式输出到 UI
#2 先返回 sources,便于 UI 在生成回答时先展示来源
#3 LangChain 的 astream 提供 token 级别流式输出
#4 每个 token 单独 yield,形成平滑的“打字效果”
#5 完成信号:UI 可收尾展示并恢复输入
#6 错误处理:即便失败也保持流式会话完整
AsyncGenerator 模式代表了 LLM 响应交付方式的根本变化:不再阻塞等待完整回答,而是信息一产生就立刻 yield。这样 UI 能马上开始展示信息:先展示找到的 sources,再逐 token 流式输出回答,形成用户熟悉的“正在输入”效果。
信息交付顺序的设计最大化用户体验:先 yield sources(C),UI 可以立刻告诉用户“哪些文档在支撑这次回答”,在模型生成答案的同时建立信心并提供上下文。
这种渐进式披露(progressive disclosure)让用户在生成过程中保持参与,也帮助他们理解即将到来的回答依据。sources 信息在后续所有 yield 中保持一致,UI 因此能在整个流式过程中持续保持上下文。很多带推理能力的聊天 UI 也会展示模型的 “thinking process”,动机相同。
核心流式机制使用 LangChain 的 astream 直接拿到模型生成的 token。每个 token 被立刻 yield,UI 可逐块追加文本,形成平滑实时输出。
显式的完成信号有多重用途:告诉 UI 生成已结束(启用输入框与按钮)、为日志记录提供最终元数据,并确保流式会话干净关闭。与此同时,完整错误处理保证即便中途失败也不会让 UI 无休止“卡住”,而是能给出有意义反馈。
token 限制也是影响延迟与系统可靠性的关键约束。LLM API 同时限制输入 token(你能发送多少上下文)与输出 token(模型能生成多少)。例如 GPT-4 支持最多 128K 输入 tokens,但你可能把输出限制在 4K 以控制成本与延迟。
超过输入上限会导致请求失败;输出上限在生成中途触顶则会截断回答。这会影响 RAG 设计:检索太多 chunk 会耗尽输入预算,导致没空间生成回答。合适的 max_tokens 配置能在回答完整性、生成时间与成本之间取得平衡,避免回答不完整或输出意外过长(更贵)。
这种健壮的流式架构把 DakkaBot 从传统 request-response 系统升级为现代对话界面,满足用户对实时、响应式 AI 交互的预期。
简单接口封装(SIMPLE INTERFACE WRAPPERS)
虽然 DakkaBotCore 使用 async/await,适合 Web 应用与并发处理,但很多测试框架与遗留系统仍期望同步函数调用。像 pytest、集成脚本与命令行工具往往不擅长处理 async,需要复杂的 event loop 管理,导致测试代码臃肿。
这里的 singleton 模式有两个目的:避免在多次函数调用中重复进行昂贵的 embedding 模型与向量索引初始化;同时提供测试框架可依赖的简单缓存机制。全局实例只创建一次并复用,使多次查询的测试套件性能大幅提升。下面这些 wrapper 在 async/sync 之间搭桥,并为测试环境提供 singleton 缓存(见清单 12.16)。
清单 12.16 Singleton 模式与同步测试封装
_dakka_bot = None #1
async def get_dakka_bot(): #2
"""Get or create DakkaBot instance"""
global _dakka_bot
if _dakka_bot is None:
_dakka_bot = DakkaBotCore()
await _dakka_bot.initialize()
return _dakka_bot
def dakka_bot_query(query: str) -> str: #3
"""Simple sync wrapper for testing frameworks"""
async def _process():
bot = await get_dakka_bot()
result = await bot.process_query(query)
if result["error"]:
return f"Error: {result['error']}"
return result["response"]
return asyncio.run(_process())
#1 singleton 防止多次昂贵初始化
#2 惰性初始化同时支持同步与异步使用模式
#3 同步 wrapper 便于集成测试框架
同步 wrapper 通过 asyncio.run() 在内部管理 async event loop,从而与现有同步代码库干净集成,同时保留 async 架构带来的性能收益。这种模式让同一套核心逻辑既能服务现代 async Web 应用,也能服务传统同步环境,而不需要复制代码。
DakkaBotCore 把简单 RAG 脚本升级为生产就绪组件:结构化输出、流式支持与全面错误处理,为构建复杂 UI 提供所需一切,同时保持核心 RAG 功能不变。
用 Chainlit 构建聊天界面(BUILDING THE CHAT INTERFACE WITH CHAINLIT)
Chainlit 的优势在于它专注对话式 AI 应用。不同于需要大量前端开发的通用 Web 框架,Chainlit 开箱即提供聊天特性组件:流式响应、对话历史与消息归因等。
要构建界面,先创建一个名为 chainlit_app_streaming.py 的文件,作为主要 Chainlit 应用入口。这个文件展示了:极少代码就能创建一个在用户体验上可对标商业 AI 助手的聊天界面,而且完全运行在你自己的基础设施上。
下面的初始化 handler 展示 Chainlit 如何管理聊天会话,并提供完整用户 onboarding。
清单 12.17 Chainlit 初始化与 DakkaBot 启动
import chainlit as cl #1
from dakka_bot_core import DakkaBotCore
from guardrail_helpers import validate_user_inputs #2
# Global bot instance
dakka_bot = DakkaBotCore() #3
@cl.on_chat_start #4
async def start():
"""Initialize DakkaBot when chat starts"""
await cl.Message(
content=(
" **DakkaBot** is starting up...\n\n"
Initializing DataKrypt knowledge base "
"and AI systems..."),
author="System" #5
).send()
try:
await dakka_bot.initialize() #6
await cl.Message(
content=""" **DakkaBot Ready!**
I'm your DataKrypt developer assistant. I can help you with:
**Encryption & Security**
- Key rotation procedures
- HSM management
- Compliance setup
**APIs & Integration**
- Authentication flows
- Service configurations
- Troubleshooting guides
**Operations & Monitoring**
- Deployment procedures
- Performance optimization
- Incident response
**Compliance & Governance**
- HIPAA, PCI DSS, SOX setup
- Audit procedures
- Data protection
**Ask me anything about DataKrypt!**
*Example: "How do I rotate encryption keys?"*""",
author="DakkaBot"
).send()
except Exception as e:
await cl.Message(
content=(
f" **Error starting DakkaBot**: {str(e)}\n\n"
"Please check your configuration and try again."),
author="System"
).send()
#1 引入 Chainlit 框架以提供聊天界面能力
#2 自定义输入校验模块:安全与内容过滤
#3 单一全局实例,避免昂贵初始化反复发生
#4 新用户开始会话时触发
#5 author 归因:区分系统消息与机器人响应
#6 异步初始化:加载向量库与 LLM 组件
应用结构遵循 Chainlit 的事件驱动模式:用装饰器处理特定用户交互。guardrail_helpers 的引入表示自定义输入校验——对生产部署很关键,因为必须净化用户输入以防 prompt injection 或不当内容。
全局实例模式体现了生产应用的关键架构决策:不是为每个用户会话重建 DakkaBotCore(昂贵且慢),而是创建一个服务所有用户的单例。这个方案需要考虑线程安全与资源共享,但能显著提升多用户场景下的响应时间与资源利用率。
@cl.on_chat_start 在用户开启新会话时触发,是做系统初始化与用户 onboarding 的最佳时机。渐进式消息策略会让用户清楚看到启动过程发生了什么:先提示正在初始化,再在成功后给出功能概览。
author 归因在聊天界面中制造清晰的视觉区分:System 消息用于管理信息(启动、错误),DakkaBot 消息用于回答用户问题。归因系统对用户信任与调试非常关键,因为它清楚标明每条消息来自哪个组件。
异步初始化调用负责在不阻塞 UI 的情况下完成昂贵任务(加载向量库、连接模型)。用户会立刻看到系统在工作,并在成功后看到全面的功能概览,从而把潜在的“等待”变成建立信心的 onboarding 体验。
流式的实时消息处理(REAL-TIME MESSAGE PROCESSING WITH STREAMING)
核心消息 handler 提供用户期待的现代流式体验,把传统 request-response 交互变成动态实时对话。该实现展示 Chainlit 如何用流式能力为定制 LLM 应用创造类似 ChatGPT 的体验,并在生成过程中维持用户参与感。下面 handler 同时包含实时状态更新与优雅错误处理。
清单 12.18 主消息处理:流式与校验
@cl.on_message #1
async def main(message: cl.Message):
"""Handle user messages with streaming response"""
if not dakka_bot.initialized: #2
await cl.Message(
content=(
" Please refresh the page."),
author="System"
).send()
return
user_query = message.content
user_query, is_valid = validate_user_inputs( #3
user_query) #3
if not is_valid:
await cl.Message(
content=user_query, # Contains error message
author="System"
).send()
return
thinking_msg = cl.Message(
content=" Searching DataKrypt documentation...", #4
author="DakkaBot"
)
await thinking_msg.send()
try:
response_msg = cl.Message( #5
content="", author="DakkaBot") #5
sources_info = None
async for chunk in
dakka_bot.process_query_stream( #6
user_query): #6
if chunk["type"] == "sources":
thinking_msg.content = ( #7
f" Found {chunk['doc_count']} " #7
"relevant documents. " #7
" Generating response..."
await thinking_msg.update()
sources_info = chunk
elif chunk["type"] == "token":
if thinking_msg:
await thinking_msg.remove() #8
thinking_msg = None
await response_msg.stream_token( #9
chunk["content"]) #9
elif chunk["type"] == "done":
await response_msg.send() #10
break
elif chunk["type"] == "error":
if thinking_msg:
await thinking_msg.remove()
await cl.Message(
content=(
f" **Error processing your question**: "
f"{chunk['content']}\n\nPlease try "
f"rephrasing your question or check "
f"if the DataKrypt documentation is "
f"properly loaded."
),
author="DakkaBot"
).send()
return
except Exception as e:
if thinking_msg:
await thinking_msg.remove()
await cl.Message(
content=f" **Unexpected error**: {str(e)}\n\nPlease try again.",
author="DakkaBot"
).send()
#1 装饰器自动处理所有用户消息
#2 防御式检查:未初始化则阻止使用
#3 输入校验:防止恶意 prompt 与内容过滤
#4 视觉反馈:展示检索与处理状态
#5 空消息容器:将通过 streaming 填充
#6 异步遍历流式响应 chunks
#7 进度更新:告知检索成功与文档数量
#8 回答生成开始时移除 loading 指示
#9 token-by-token 流式输出形成“打字效果”
#10 完成消息展示并恢复用户输入
@cl.on_message 装饰器会自动捕获所有用户输入,使你能采用干净的事件驱动架构:框架负责消息路由与会话管理。初始化检查体现了生产部署所需的防御式编程:组件未就绪时,不是崩溃或卡住,而是给出清晰反馈,帮助用户理解发生了什么以及如何修复。
输入校验是抵御 prompt injection 与不当内容的第一道防线。validate_user_inputs 可以在开始昂贵的 LLM 处理之前,净化 query、检测恶意模式并执行内容策略。早期校验也避免浪费算力与引入潜在安全漏洞,同时能给用户更有帮助的输入提示。
“thinking” 指示解决 LLM 应用中的关键体验问题:从提交到可见进度之间的延迟。通过立即显示系统正在工作,用户知道输入已被接收并开始处理,避免界面无响应带来的焦虑,并为多步骤 RAG 流程建立预期。
流式实现展示了 async generator 在实时体验中的价值:不等完整回答,而是信息就绪即显示。渐进式消息更新持续告知检索是否成功,先建立“找到了相关信息”的信心,再进入生成。
从 thinking 到回答的无缝切换让体验更精致:当内容开始出现时,loading 指示立刻消失。token-by-token 的输出模拟自然对话节奏,让 AI 更“有回应”。完成信号确保正确收尾并重新启用输入,维持用户期待的对话流。
这套流式架构把 DakkaBot 从传统问答系统升级为更具参与感的对话伙伴:提供即时反馈、维持用户注意力,并以用户习惯的响应方式交付信息。
运行应用(RUNNING THE APPLICATION)
现在我们来试用你为 DakkaBot 构建的新流式功能。这会自动启动一个 Web server(通常在 http://localhost:8000)并打开浏览器进入聊天界面。该 Chainlit 应用提供多项生产就绪特性:
- 渐进加载:用户能看到启动进度与检索状态
- 输入校验:通过安全护栏防止恶意 prompt injection
- 流式响应:token-by-token 展示,复刻 ChatGPT 体验
- 错误处理:优雅降级并提供有用错误信息
- 状态指示:文档搜索与生成期间提供可视化反馈
最终效果是一个任何用过现代 AI 助手的人都觉得熟悉的聊天界面,但它的回答严格锚定在你公司特定文档之上,同时在你自己的基础设施内保持完整数据隐私。用户现在可以问诸如 “How do I rotate encryption keys?” 这类问题,并获得流式响应:体验与商业 AI 服务同样顺滑,但完全由 DataKrypt 的内部知识库驱动。
12.4 LLM 应用的可观测性(Observability for LLM applications)
传统可观测性——监控 CPU、内存与请求延迟——能告诉你“哪里坏了”,但解释不了“为什么你的 LLM 应用会给出糟糕答案”。LLM 可观测性需要追踪推理链(reasoning chain) :检索到了哪些文档、上下文如何被组装、模型为何生成特定输出、推理在哪一步失败。
不同于传统软件可以用断点与堆栈追踪来调试,LLM 应用需要理解信息如何流经检索(retrieval) 、增强(augmentation) 与生成(generation) 阶段。你需要看到哪些文档影响了回答、模型如何解读上下文,以及幻觉或不相关回答从何而来。
12.4.1 通过 Docker 搭建 Langfuse(Set up Langfuse via Docker)
在本地搭建 Langfuse 为全面的 LLM 可观测性打下基础。开源版本让你完全掌控数据,同时提供与云服务同样强大的 tracing 与监控能力:
% git clone https://github.com/langfuse/langfuse.git
% cd langfuse
% docker compose up
这会启动完整的 Langfuse 栈,包括 Web 界面、数据库与 worker 进程。容器跑起来后,访问 http://localhost:3000/ 进入 dashboard。初始配置步骤如下:
- 通过 Web 界面创建管理员账号。
- 建立你的第一个组织(organization)与项目(project)。
- 配置团队访问与权限。
- 为 DakkaBot 集成生成 API keys。
Docker 部署会自动处理所有依赖,非常适合开发与小规模生产部署。对于企业使用场景,Langfuse 也支持 K8s 部署与云托管选项,并能与你现有的基础设施监控集成。
12.4.2 把 Langfuse 接入 DakkaBot(Integrating Langfuse with DakkaBot)
当 Langfuse 跑起来后,把它接入我们的 RAG 流水线只需要几行代码。我们来给 RAG 示例增加全面可观测性。
只要加入 callback handler,Langfuse 就会自动追踪 LLM 调用:包含输入 prompts、输出 responses、token 用量与延迟。它还会捕获检索操作(包括 query embeddings 与被检索文档的元数据)。chain 的执行会被记录为逐步的流水线执行与时间统计。错误追踪会显示失败操作的堆栈与上下文;成本追踪则监控每次查询的 token 用量与预估 API 成本。
12.4.3 在 DakkaBotCore 中增强可观测性(Enhanced observability in DakkaBotCore)
面向生产使用,我们把 Langfuse 集成进 DakkaBotCore 类,实现全面监控。这个集成把 DakkaBot 从“不透明系统”变成一个完全可观测的应用:每次交互、性能指标与失败模式都能被捕获与分析。无缝集成确保可观测性不干扰核心功能,同时提供生产运维所需的可见性。下面示例用极少的代码改动实现全面 LLM 可观测性(见清单 12.19)。
清单 12.19 Langfuse 可观测性集成与 tracing
class DakkaBotCore:
def __init__(self):
... #1
self.langfuse_handler = None #2
async def initialize(self):
try:
...
self.llm = ...
langfuse = Langfuse(
public_key=os.environ[ #4
"LANGFUSE_PUBLIC_KEY"], #4
secret_key=os.environ[ #4
"LANGFUSE_SECRET_KEY"], #4
host=os.environ.get( #4
"LANGFUSE_HOST", #4
"http://localhost:3000") #4
) #4
self.langfuse_handler = CallbackHandler() #5
self.initialized = True
except Exception as e:
raise RuntimeError(f"Failed to initialize DakkaBot: {str(e)}")
async def process_query(self, query: str) -> Dict[str, Any]:
try:
docs = ...
context = ...
prompt = ...
response = self.llm.invoke(
prompt,
config={
"callbacks": [self.langfuse_handler], #6
"metadata": {
"query": query, #7
"doc_count": len(docs), #7
"context_length": len(context), #7
"user_session": "demo" #7
}
}
)
return {
"response": response.content,
"sources": [...], # Source processing
"doc_count": len(docs),
"error": None
}
except Exception as e:
return {
"response": "Sorry! Please try again!",
"sources": [],
"doc_count": 0,
"error": str(e)
}
#1 见清单 12.13 中已展示部分
#2 Langfuse handler 作为实例变量复用
#4 通过环境变量安全管理凭证
#5 单个 handler 实例追踪该会话中的所有操作
#6 callback 自动捕获检索等操作
#7 自定义 metadata 提供业务上下文,便于分析
callback handler 的初始化为所有 LangChain 操作建立了自动埋点。一旦配置好,这个单一 handler 就能捕获检索操作、LLM 生成调用,以及其他 LangChain 组件的详细 traces,而无需在应用各处到处改代码。这种“设一次,到处 trace”的方式把实现负担降到最低,同时最大化可观测性覆盖面。
增强版 LLM 调用展示了如何在默认技术指标之外,用业务上下文丰富 trace。Langfuse 会自动捕获输入 prompt、输出 response、token 用量与延迟;而自定义 metadata 则补充了理解系统行为所需的领域信息。查询分析、检索成功率、上下文使用模式与用户会话信息等,使得分析能力不再停留在性能监控层面。
这个可观测性集成让团队能够用数据驱动方式优化 DakkaBot 性能:识别哪些类型的查询需要最多上下文、基于文档使用模式优化检索策略,并在用户察觉前发现性能退化。全面 tracing 把 DakkaBot 从黑盒系统变成透明、可度量的应用:每个决策都可被理解、分析并改进。
当你用已埋点的 DakkaBot 跑过一些查询后,Langfuse dashboard 会把原本不透明的 LLM 操作变成透明、可度量的系统。访问 http://localhost:3000 可以看到 RAG 流水线在性能、成本效率与运维健康方面的全景视图(图 12.8)。该 dashboard 提供四个关键监控视角,可用于同时优化用户体验与资源利用。
图 12.8 Langfuse dashboard:展示 DakkaBot 的 traces、成本与延迟指标
dashboard 揭示了 DakkaBot 的一些关键性能特征。首先,trace 时间线显示出清晰的使用模式:业务时间段出现峰值,说明开发团队确实在使用该文档查询系统。
接着,成本追踪显示数百次查询总共只花了略高于 60 美分,资源利用效率不错;但成本会随 provider 与模型选择显著变化。这个部署使用的是 Google Gemini 1.5 Pro;切换到 GPT-4 会让成本变为三倍,而换成更便宜的 Gemini Flash 则可能把成本降低 90%。理解你所用 provider 的 token 定价对准确预算规划与经济可行性评估非常关键。最重要的是,延迟指标提供了可执行的性能数据,例如响应时间、处理速度与优化效果。
该 Langfuse dashboard 为 DakkaBot 的 RAG 流水线提供全面可观测性,覆盖四个关键监控区域(见图 12.8):
- Traces by time——在指定时间窗口(此处为 24 小时)展示 traces 总量及使用峰值,用以判断开发者何时在积极查询文档系统。
- Model Usage——跟踪
gemini-2.5-flash的成本为 $0.617878,实现对 LLM 操作的精确成本归因。 - User consumption——展示 token 成本。该多维视图让团队同时监控运维健康与业务指标:既能定位性能瓶颈,又能追踪 RAG 实现的成本效率。
- Trace latency percentiles / Generation latency percentiles / Span latency percentiles——展示不同流水线组件的性能表:trace 延迟(端到端请求处理)、生成延迟(LLM 响应时间与处理速度)、以及 span 延迟(针对组件性能的个体评估指标,用于定向优化,例如 Answer relevancy 与 Correctness)。
该 dashboard 把不透明的 LLM 操作变成透明、可度量系统,让团队同时为用户体验与运维效率做优化。Langfuse 的 trace 详情视图(图 12.9)进一步提供对单次 DakkaBot 交互的细粒度可见性,展示完整的请求-响应周期,用于调试与优化。
图 12.9 Langfuse 展示详细 traces:包含成本、输入、输出与中间结果
trace header 显示关键指标:11.48s 延迟、1,408 输入 tokens → 2,359 输出 tokens,以及该查询 $0.00632 的成本。主面板展示完整对话流:从 DakkaBot 的 system prompt(“You are DakkaBot, DataKrypt’s expert developer assistant . . .”)开始,随后是文档上下文与用户问题。
这种 trace 级别细节让开发者能准确看到“给 LLM 提供了哪些上下文”以及“它如何处理这些上下文”。这种细粒度可观测性对于调试错误(如回答不正确)、性能瓶颈或意外 API 交互至关重要,把 LLM 应用的黑盒变成完全透明、可调试的系统。
12.4.4 超越传统指标(Beyond traditional metrics)
标准指标会漏掉 LLM 应用最重要的细微失败。CPU 利用率与响应时间能告诉你系统何时坏了,却解释不了为什么 RAG 系统给出不相关答案,或者为什么用户对“技术上正确但没用”的回答不满意。
为什么准确率与延迟不够(WHY ACCURACY AND LATENCY AREN’T ENOUGH)
传统指标为确定性系统设计,成功与失败是二元的。而 LLM 应用处于概率世界里,“正确性”是一个连续谱,用户满意度也依赖简单指标无法捕捉的因素。
- 准确率问题:二元对错无法覆盖“部分正确”或推理质量——回答可能事实准确但偏离用户意图。传统准确率也不衡量对相似 prompt 的一致性——同一问题稍作改写可能得到质量差异巨大的回答。它忽略了 LLM 特有失败模式,如幻觉、偏见或不安全输出。最后,它也无法评估创意、语气或风格等主观任务,而这些对体验影响很大。
- 延迟局限:对流式应用而言,首 token 时间与总生成时间的重要性不同;用户会认为有打字效果比一次性返回更“快”。传统延迟指标不反映用户感知——3 秒响应但立即显示来源,会比 2 秒响应但完全无反馈更“快”。它也忽略资源利用模式——向量检索阶段内存使用会突增,且 token 消耗随查询复杂度剧烈变化。
追踪多步骤推理(TRACING MULTISTEP REASONING)
RAG 系统涉及复杂推理链,失败可能发生在任何阶段。你需要对完整推理过程具备可观测性,才能知道哪里出了问题、该如何提升。
你需要看到推理路径是否合理——文档检索与上下文组装的中间步骤是否逻辑正确、是否匹配问题。错误传播分析能显示推理在哪一步断裂——检索、上下文组装还是生成。token 分配监控能揭示每个推理步骤使用了多少上下文,以及是否高效。分支点分析能显示模型在哪里考虑了替代方案,以及是什么影响了最终决策。
Langfuse 等工具通过细粒度 span 追踪提供这种可见性:展示查询如何流经检索、增强与生成阶段,并给出每一步的耗时与 token 使用情况。
token 消耗监控(TOKEN CONSUMPTION MONITORING)
token 用量直接影响生产 LLM 应用的成本与性能。不同于传统算力指标,token 消耗会随查询复杂度、上下文长度与回答细节不可预测地变化。
关键生产指标包括:输入/输出 token 比率(效率指标,用于优化 prompt 与上下文组装);上下文利用率(可用上下文窗口中有多少是有用信息、多少是浪费的 padding);按查询类型的成本分析(不同问题类型成本差异巨大——排障类可能比简单事实类多 10 倍 tokens);token 浪费检测(识别重复生成或不必要冗长回答,这会抬高成本但不提升质量)。
ML 基础设施的演进(THE EVOLUTION OF THE ML INFRASTRUCTURE)
LLM 不会替代传统 MLOps 基础设施——它是在其上增加新的组件与复杂性。你的 K8s 集群、监控流水线与 CI/CD 系统仍然必要,但现在必须支撑向量数据库、prompt 管理系统与专用可观测性工具,这些在传统 ML 工作流中并不存在。
从“单模型部署”到“复合 RAG 系统”的转变,会根本改变你的系统架构方法。你的“模型”变成一个编排流水线:embeddings、retrievers、LLMs 与 UIs 的组合体。每个部分都需要围绕性能、可靠性与可观测性做精细设计。分布式架构带来更大灵活性,但也引入新的失败模式与调试挑战。
RAG 架构让 LLM 在不进行昂贵重训的情况下访问私有且最新的信息。通过把检索与生成解耦,你可以独立更新知识库,同时保持模型行为一致。这种可组合方法带来灵活性,也带来需要系统化可观测性才能管理的运维复杂度。
现代 LLM 应用还要求实时用户体验:流式响应、来源归因与优雅错误处理。像 Chainlit 这样的框架把后端 RAG 流水线与直观聊天界面连接起来,使你能快速原型出满足用户对响应式 AI 交互预期的生产级应用。
当调试多步骤推理链时,传统调试方法会失效,因此全面可观测性变得至关重要。Langfuse 等工具提供对检索质量、prompt 有效性、token 消耗与成本归因的可见性——这些洞察是仅靠传统监控无法获得的,却是优化 LLM 系统性能的必需品。
你在这里学到的架构模式——可组合组件、流式界面与全面 tracing——构成任何 LLM 驱动系统的基础:从内部文档助手到面向客户的 AI 应用。这些模式确保系统在扩展为服务真实用户、满足真实业务需求时仍然可观测、可维护、可优化。
小结(Summary)
- RAG 的力量来自“解耦”。检索与生成分离,因此你可以更新知识库而无需重训模型。文档变化;LLM 不受影响。
- Temperature = 0 并不等于完全确定;它足够一致以便测试,但不可完全复现,因此要按此规划。传统断言失效;语义评估取代精确匹配。
- token 预算是架构约束:50 chunks × 500 tokens 在生成前就消耗 25,000 tokens。超过输入上限请求会失败;触顶输出上限回答会在半句被截断。
- 在花钱之前先校验。输入净化发生在 LLM 调用之前——安全与成本效率一举两得。prompt injection 尝试会被早期拦截,同时 token 预算得以保全。
- 传统指标在这里“看不见”。CPU 与延迟告诉你什么时候坏了,而 tracing 告诉你为什么答案不好。Langfuse 等工具让你看到检索质量、prompt 有效性与推理链,这是 Prometheus 做不到的。
- 你的“模型”现在是一条流水线。prompts、embeddings、检索参数与 LLM 配置彼此独立做版本管理。system prompt 的改动对行为的影响不亚于换模型版本,因此要把 prompt 当代码。
- 先用 LangChain,再进阶 LangGraph。顺序链适合直接 RAG,图结构解锁条件分支、并行执行与迭代精炼。按工作流复杂度选择工具。