LangChain 实战:知识库客服机器人

QuibblerAgentQuibblerAgent 2026-11-06 约 26 分钟 1 次阅读

第 9 篇立完回归纪律,系列攒下的能力清单已经齐整:模型入口、提示模板、结构化输出、工具调用、LCEL 链、agent、双记忆、RAG、评测——九块积木各自单独验过收,却还没在同一个程序里同框工作过。本篇是《LangChain 从入门到实战》第 10 篇,也是收官篇:把它们全部装进一个真实项目——知识库客服机器人,从需求一路走到评测验收。单看任何一行代码都是旧知识,合在一起是一台能交付的机器。

项目设定:云看板是一款虚构的团队协作看板工具,代指任何有产品文档的软件,官方文档以 markdown 形式放在 docs 目录。客服机器人要答准产品问题、答不了转工单、同会话连续追问不失忆、跨会话记住用户偏好。本文按"需求与架构 → 模块走读 → 运行实录 → 评测验收 → 对照与扩展"组织全文;依赖沿用第 1、8、9 篇的口径:主包加 langchain-openai 与 langchain-text-splitters,评测另装 langsmith。

1、需求与总体架构

把"产品文档客服"翻译成五条可验收的需求,每条正好对应系列的一块积木,对齐如下:

需求 能力 回扣篇目
产品问题答得准 RAG 检索 第 8 篇
答不了转人工 工单工具 第 4 篇
同一会话连续追问 会话记忆 第 6 篇
跨会话记住偏好 长期记忆 第 7 篇
改动有分数背书 评测验收 第 9 篇

五行缺一都会露馅:缺检索会编造、缺工单用户卡死、缺会话记忆用户每轮自报家门、缺长期记忆偏好记不住、缺评测调优回到玄学。五条需求同样决定了架构的三个层次:前两条是工具层,中间两条是记忆层,最后一条是流程层。整体架构如下:

flowchart TD A[产品文档] --> B[切块嵌入] B --> C[向量库] C --> D[检索工具] D --> E[agent 装配] F[工单工具] --> E G[偏好记忆工具] --> E H[会话记忆] --> E I[长期记忆库] --> G E --> J[主服务循环] J --> K[运行验收] C --> L[黄金集评测] L -->|不达标调参| C

图里三条线索值得点透。知识库建设在左侧离线跑:文档切块入库一次,检索工具与评测共用同一个向量库,文档更新只需重跑这条线、问答服务不动——第 8 篇"两段分离"的工程意义落到项目里。中间是装配:四个工具交给 create_agent,checkpointer 与 store 双记忆挂在两侧,分别管会话历史与用户偏好。右下是闭环:评测直接打在检索层,分数不达标就回去调参——第 9 篇的回归纪律第一次以代码形式进入项目。

2、知识库切块入库

索引段照搬第 8 篇五步的前四步:加载用原生构造 Document(多文件遍历比加载器直白),切分用 RecursiveCharacterTextSplitter,嵌入入库用 InMemoryVectorStore,完整代码如下:

from pathlib import Path
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore

docs = [Document(page_content=md.read_text(encoding="utf-8"),
                 metadata={"source": md.name})
        for md in Path("docs").glob("*.md")]

splitter = RecursiveCharacterTextSplitter(chunk_size=500,
                                          chunk_overlap=80)
splits = splitter.split_documents(docs)

vector_store = InMemoryVectorStore(
    OpenAIEmbeddings(model="text-embedding-3-large"))
vector_store.add_documents(splits)          # 嵌入入库一行完成
retriever = vector_store.as_retriever(search_kwargs={"k": 4})

参数手感沿用第 8 篇的结论:产品单篇文档不长,chunk_size 取 500、重叠 80;k 先取 4,第 6 节的评测会让这个数字接受审判。有一处伏笔:metadata 里的 source 一物两用——既在检索结果里给答案标注出处,又是命中率评估器的断言依据。retriever 在这一步就建好,工具层与评测层共用同一份索引,避免验收与上线两套索引;切块参数的取舍见官方 RAG 教程。

3、定义四个工具

工具是 agent 的手脚,描述是模型路由的唯一依据(第 4 篇),四个工具的 docstring 都按"做什么、什么时候用"写足信息量。

3.1、检索与工单工具

检索工具把第 8 篇的检索器与第 5 篇的管道收进一个函数;工单工具用第 3 篇的 Pydantic schema 锁死参数。检索与工单两个工具的定义如下:

from typing import Literal
from pydantic import BaseModel, Field
from langchain_core.runnables import RunnableLambda
from langchain_core.tools import tool

def format_docs(hits):                    # 检索块拍平成带来源的文本
    return "\n\n".join(f"[{h.metadata['source']}]\n{h.page_content}"
                       for h in hits)

retrieve = retriever | RunnableLambda(format_docs)  # 第 5 篇的管道

@tool
def search_docs(question: str) -> str:
    """按问题语义检索云看板产品文档,返回带来源的片段。
    回答功能、价格、操作步骤前必须先调用本工具。"""
    return retrieve.invoke(question)

tickets = []                              # 演示用,生产接工单系统

class TicketInput(BaseModel):
    summary: str = Field(description="一句话概括用户遇到的问题")
    priority: Literal["低", "中", "高"] = Field(
        default="中", description="功能不可用或用户明确不满取高")

@tool(args_schema=TicketInput)
def create_ticket(summary: str, priority: str = "中") -> str:
    """检索结果无法解决用户问题时,创建人工工单转人工跟进。"""
    return f"已建工单 TK-{1000 + len(tickets)}({priority}),1 个工作日内跟进。"

四点解读。retrieve 一行是第 5 篇 LCEL 的直接复用:检索器天生是 Runnable,与 RunnableLambda 串成管道,工具函数里只剩一次 invoke。search_docs 的描述写明"必须先调用",这是给路由上的第一道约束——没有它,模型会拿训练记忆硬答产品问题。TicketInput 的 priority 用 Literal 枚举加描述,"功能不可用或用户明确不满取高"这句话就是模型判优先级的全部依据,第 3 篇"描述写给模型看、约束留给校验器"在工具上的落点。tickets 是演示替身,换成工单系统 API 时签名一行不用改。

安全提示:建工单是写操作。描述里"解决不了再建"与系统提示的双保险之外,生产上还应加调用限流与审计日志——工具的执行权在你手里,第 4 篇说的安全边界落到真实项目就是这一行防御。

3.2、偏好记忆工具

长期记忆按第 7 篇官方推荐的模式做:读写都是工具,记什么、何时记由 agent 决定,runtime 注入 store 与用户身份。两个记忆工具的定义如下:

from dataclasses import dataclass
from langchain.tools import ToolRuntime   # tool 沿用 3.1 的导入

@dataclass
class Context:                            # 请求级上下文:谁在说话
    user_id: str

@tool
def save_preference(preference: str,
                    runtime: ToolRuntime[Context]) -> str:
    """把用户明确表达的偏好写入长期记忆,如回复要简短。"""
    runtime.store.put(("preferences", runtime.context.user_id),
                      preference, {"text": preference})
    return "已记住。"

@tool
def recall_preferences(runtime: ToolRuntime[Context]) -> str:
    """读出该用户已记住的全部偏好,回答前先调用。"""
    items = runtime.store.search(
        ("preferences", runtime.context.user_id))
    return ";".join(it.value["text"] for it in items) or "暂无记录"

三点解读。runtime 参数由框架注入,不进模型可见的签名——模型看到的 save_preference 只有一个 preference 参数。偏好写入的命名空间是 ("preferences", user_id),与线程号无关,任何会话召回的都是同一份,第 7 篇"checkpointer 管一个线程、Store 管一个人"的分工在此落地。recall_preferences 的描述同样写了调用时机,与 3.1 的路由约束同一手法;这套模式的官方口径见长期记忆文档。

4、装配双记忆 agent

4.1、create_agent 装配

四件东西拼装:模型、四个工具、系统提示、双记忆。装配代码如下:

from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore

model = init_chat_model("openai:gpt-6.1-terra", temperature=0)

agent = create_agent(
    model,
    [search_docs, create_ticket,
     save_preference, recall_preferences],
    system_prompt="你是云看板的客服专员:产品问题先检索文档,"
                  "资料不足就明说,不要编造;检索解决不了再建工单;"
                  "用户表达偏好先记住再回答。",
    checkpointer=InMemorySaver(),   # 短期记忆:会话内历史
    store=InMemoryStore(),          # 长期记忆:跨会话偏好
    context_schema=Context,         # 请求上下文:用户身份
)

解读:create_agent 一行收编执行循环(第 6 篇),checkpointer 管会话内历史、store 管跨会话偏好(第 7 篇),context_schema 让用户身份随每次调用传入而不占模型上下文。系统提示按第 2 篇的思路收成常量:人设、护栏、流程各一句,改口径只动这一处。参数语义以官方 Agents 文档为准。

双记忆此处都是内存版:InMemorySaver 与 InMemoryStore 重启即失。生产按第 7 篇换 SqliteSaver、PostgresSaver 与 PostgresStore(见持久化文档),装配代码一个参数都不用改。

4.2、主服务循环

主循环是十几行 CLI:每会话一个 thread_id,每个用户一个 context。代码如下:

def chat(session: str, user: str):
    cfg = {"configurable": {"thread_id": session}}
    ctx = Context(user_id=user)        # 身份不进模型上下文
    print(f"进入会话 {session}(用户 {user}),输入 q 退出")
    while True:
        q = input("你:")
        if q.strip().lower() == "q":
            break
        r = agent.invoke(
            {"messages": [{"role": "user", "content": q}]},
            config=cfg, context=ctx)
        print("客服:", r["messages"][-1].content)

chat("s-001", "user-001")

三点解读。cfg 与 ctx 在会话开始时构造一次、循环里原样复用——thread_id 隔离会话,user_id 隔离偏好,两个键互不纠缠。invoke 的三参形态是第 7 篇的写法:消息、config、context 各归各位。取最终回答依旧是 r["messages"][-1].content(第 6 篇的读法);要亲眼看每轮调了哪些工具,把 invoke 换成 stream(stream_mode="updates") 打一遍轨迹即可。

5、三段对话运行实录

python cs_bot.py 启动,先在会话 s-001 连问三句,再换会话 s-002 验证长期记忆,一次典型运行如下:

进入会话 s-001(用户 user-001),输入 q 退出
你:云看板支持离线使用吗?
客服:支持。移动端在设置里开启"离线模式",可查看并编辑最近
打开的看板,联网后自动同步;桌面端暂不支持离线编辑。

你:我配了 Webhook,收不到推送,帮我处理一下。
客服:文档里的常见原因都排查过:回调地址未验证、签名不匹配、
防火墙拦截,均不符合你的情况。已创建工单 TK-1001(优先级高),
1 个工作日内有同事联系你核对配置。

你:以后回复都简短一点,帮我记住。
客服:已记住:回复保持简短。

进入会话 s-002(用户 user-001),输入 q 退出
你:免费版最多几个人用?
客服:免费版上限 5 人,更多请升级专业版。

三段各验一层。第一段普通问答:search_docs 被调用,答案细节全部来自文档块,检索结果自带的 [mobile.md] 出处被模型转述成自然语言,可溯源是客服场景的硬指标。第二段建工单是两步工具链:模型先调 search_docs 排查,判断检索解决不了,再调 create_ticket,priority 按 schema 描述取了"高"——用户明确说功能不可用,第 3 篇的字段描述与第 4 篇的描述即路由在此同时兑现。第三段验双记忆:第三问触发 save_preference 当场写入;换到 s-002 后 thread_id 变了、短期记忆清零,但回答只有一句——recall_preferences 从 Store 召回了偏好,长期记忆跨会话生效。

6、黄金集评测验收

第 9 篇的结论直接搬来验收:答案质量的瓶颈几乎总在检索不在生成,先给检索层立命中率护栏。黄金集共 10 问:8 条文档问按"每篇文档至少一问"从真实客服日志分层抽取,2 条越界问考"建工单或明说没有"、用手写断言循环单独验收。走命中率的部分只有 8 条,代码如下:

from langsmith import Client
from langsmith.evaluation import evaluate

golden = [                              # 8 条文档问,2 条越界问见正文
    {"q": "免费版最多几个成员?", "src": "billing.md"},
    {"q": "专业版怎么开票?", "src": "billing.md"},
    {"q": "移动端怎么开离线模式?", "src": "mobile.md"},
    {"q": "桌面端能离线编辑吗?", "src": "mobile.md"},
    {"q": "Webhook 怎么配置?", "src": "webhook.md"},
    {"q": "推送收不到怎么办?", "src": "webhook.md"},
    {"q": "怎么导出看板数据?", "src": "export.md"},
    {"q": "看板能分享给非成员吗?", "src": "faq.md"},
]

client = Client()
ds = client.create_dataset(dataset_name="客服黄金集")
client.create_examples(inputs=[{"question": c["q"]} for c in golden],
                       outputs=[{"source": c["src"]} for c in golden],
                       dataset_id=ds.id)

def retrieve_target(inputs: dict) -> dict:   # 只测检索不测对话
    hits = retriever.invoke(inputs["question"])
    return {"sources": [h.metadata["source"] for h in hits]}

def retrieval_hit(run, example) -> dict:
    return {"key": "retrieval_hit", "score": int(
        example.outputs["source"] in run.outputs["sources"])}

evaluate(retrieve_target, data="客服黄金集",
         evaluators=[retrieval_hit], experiment_prefix="cs-bot")

一次典型运行的控制台摘录如下:

8it [00:14, 1.74s/it]
Evaluation Report (data=客服黄金集): retrieval_hit 0.875

7/8 命中,未中的是"怎么导出看板数据":export.md 的块排在第 5 位,k 取 4 天然够不着——与第 9 篇 checkpointer 那问同款悬案。把 search_kwargs 的 k 调到 6 重跑,第二轮实验 8/8 全中,代价是每问多喂两块上下文,token 与噪声同涨,这笔账由产品定夺,但底线一句话:分数不涨的改动不合入。答案层面的开放式验收交给 LLM as judge:手写 judge 用 with_structured_output 定义"理由加分数"的裁决结构,method 随集成包默认——OpenAI 走 json_schema,Anthropic 走 function_calling,第 9 篇第 5 节的写法原样适用。评测口径详见LangSmith 评估指南。

7、九篇能力对照

收官之际,把前九篇与本项目逐一对齐,各篇的落点如下:

篇目 本篇落点
第 1 篇:定位与第一次调用 init_chat_model 一处声明,换模型只改前缀字符串
第 2 篇:提示模板 客服人设与护栏收进 system_prompt,单独可评审
第 3 篇:结构化输出 工单 args_schema 的字段描述与 Literal 枚举
第 4 篇:工具调用 @tool 定义三工具,描述分工决定路由质量
第 5 篇:LCEL 链 检索器与 RunnableLambda 串成管道收进工具
第 6 篇:create_agent 装配主体,stream 的 updates 模式做轨迹调试
第 7 篇:记忆与状态 双记忆装配:checkpointer 与 store 各管一层
第 8 篇:RAG 组件 切块嵌入入库,as_retriever 供检索与评测共用
第 9 篇:评测实战 黄金集命中率立基线,分数回归纪律

九个落点没有一处是复习,每个 API 都在生产位置干活;第 1 篇说框架的价值在第十次调用兑现,本篇就是那第十次——积木还是那九块,合在一起是一台能交付的机器。

8、扩展方向

两个方向接着本篇往外走。流程要更硬的控制时上 LangGraph:高优工单先人工审批再发出、按问题类型路由到不同处理图、多 agent 分工协作,create_agent 固定的双节点图不再够用。搭图的语法就是它内部那套模型节点、工具节点与条件边,《LangGraph 从入门到实战》系列逐篇拆开,工单审批中断正是其中的典型案例。

上线后补运行闭环:LangSmith 的 tracing 记录线上每轮对话的完整轨迹与 token 成本,从轨迹里挑 badcase 回流黄金集。第 9 篇的回归纪律由此从离线演练变成日常运转,评测闭环与监控闭环合成一张网。

9、总结与练习

回顾全文:收官项目把九块积木装进同一台机器——第 8 篇五步建知识库,第 4 篇装饰器与第 3 篇 schema 定义三个工具,第 5 篇管道收进检索工具,第 6、7 篇的 create_agent 加双记忆完成装配,三段对话各验一层能力,第 9 篇的黄金集命中率立住回归护栏。对于准备动手的第一个 agent 项目,建议照本篇顺序落地:知识库、工具、装配、主循环、评测五步各自可独立验证,出问题时定位到模块而不是玄学;内存实现跑通后按第 7 篇换落盘件,即可上生产。

练习两道:

  • 加一个退订工具 cancel_subscription:首次调用只复述套餐与生效日期请用户确认,用户回复确认才执行,用两段式对话模拟审批
  • 把双记忆换成落盘实现(SqliteSaver 与 PostgresStore),重启进程后用原会话号复问验短期记忆,换新会话问偏好验长期记忆

相关推荐

获取系统SDK版本、判断手机ROM
精选Code

获取系统SDK版本、判断手机ROM

Build获取系统SDK版本Android中部分API的使用,需要在特定的SDK版本之后才能使用,因此在兼容老版本SDK的时候,经常需要判断API的版本。各种Android版本的对应关系参考《Android各版本对应的SDK版本》判断手机ROM有时候需要判断手机系统的ROM,检测ROM是MIUI、EMUI还是Flyme,可以使用getprop命令,去系统build.prop文件(关于build.p

4.2k
AndroidStudio中各种中文乱码问题
精选Code

AndroidStudio中各种中文乱码问题

AndroidStudio中各种中文乱码问题1、编译Java错误信息乱码在出现这个Annotation processors must be explicitly declared now...问题的时候,正好也发现AndroidStudio Build Output错误信息都乱码。经常遇到各种问题,习惯了,现在遇到的问题,都是以后的答案。 1.1、修改项目build.gradle(无效)在整个p

2.8k
PydanticAI 详解:类型安全的 Python AI SDK
精选AI

PydanticAI 详解:类型安全的 Python AI SDK

PydanticAI 详解:类型安全的 Python AI SDK写 LLM 应用最疼的两件事:模型返回的 JSON 不合预期、换一家模型商要改一串代码。pydantic/pydantic-ai(PydanticAI)用 Pydantic 团队的老本行回答了第一件——输出用 BaseModel 定义、运行前先校验,错误从运行时提前到编码时;第二件则用"模型即字符串"回答:换个厂商只改一个参数。如今

83