LangChain 在 Agent 开发中的定位:10 个模块(含代码对比,耳机售后案例)

40 阅读30分钟

0. 先说核心结论:LangChain 到底解决了什么问题

在一个真实 Agent(比如售后客服 Agent)里,你会同时遇到这些工程问题:

  • 模型厂商 SDK 不统一(OpenAI / Anthropic / Gemini / 本地模型)
  • Prompt 组织混乱(系统提示词、用户提示词、变量注入)
  • 输出不稳定(有时 JSON,有时自然语言)
  • 工具调用协议不统一(查订单、查质保、建工单)
  • RAG 流程重复造轮子(加载、切分、向量化、检索)
  • 多轮上下文难维护(用户分几轮补信息)
  • 线上稳定性治理(超时、重试、降级)
  • 追踪排障困难(为什么没走换货)

LangChain 的定位就是:把这些“重复底层工程”做成统一中间层
你可以把它类比为 AI 应用里的 JDBC + 调用链框架 + 统一工具协议层


1. 统一业务场景(贯穿全文)

用户第一句话:上周我买的一个耳机坏了

Agent 要完成:

  1. 识别意图(售后/其他)
  2. 抽取槽位(订单号、购买时间、故障描述、是否在保)
  3. 缺信息就追问(多轮)
  4. 检索并命中政策(7天退/15天换/质保修)
  5. 调用工具(查订单、建工单)
  6. 输出结构化结论给下游系统

2. 十个模块总览(先看全貌)

  1. 模型标准层(Model Abstraction)
  2. Prompt 工程层(Prompt Templates)
  3. 输出控制层(Structured Output)
  4. 工具调用层(Tools)
  5. RAG 数据接入层(Load/Split/Embed/Retrieve)
  6. 检索增强策略层(Re-rank / Hybrid / Query Rewrite)
  7. 链式编排层(LCEL / Runnable)
  8. 多轮上下文与记忆层(Message History / Memory)
  9. 可观测与评估层(Tracing / Eval)
  10. 运行治理层(Retry / Timeout / Fallback / Cache)

说明:并不是 10 点都必须写很多代码;有些是“能力层与工程策略”。


3. 模块展开(按“要解决什么 -> LangChain 怎么做 -> 代码对比”)

3.1 模型标准层(像 JDBC)

3.1.1 你自己直接写(不用 LangChain)

// 目的:对不同厂商模型做统一调用
// 问题:你要自己处理不同 SDK 的参数、返回结构、异常类型
async function callModelWithoutLangChain(
  provider: 'openai' | 'anthropic',
  prompt: string,
) {
  if (provider === 'openai') {
    // OpenAI SDK 的调用方式
    const res = await openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages: [{ role: 'user', content: prompt }],
    })
    // OpenAI 返回结构
    return res.choices[0]?.message?.content ?? ''
  }

  // Anthropic SDK 的调用方式
  const res = await anthropic.messages.create({
    model: 'claude-3-5-sonnet-latest',
    max_tokens: 1024,
    messages: [{ role: 'user', content: prompt }],
  })
  // Anthropic 返回结构不同,你又要单独处理
  const first = res.content[0]
  return first?.type === 'text' ? first.text : ''
}

3.1.2 用 LangChain

import { ChatOpenAI } from '@langchain/openai'
import { ChatAnthropic } from '@langchain/anthropic'

// 目的:把“厂商差异”收敛到初始化阶段
function createModel(provider: 'openai' | 'anthropic') {
  if (provider === 'openai') {
    return new ChatOpenAI({ model: 'gpt-4o-mini' })
  }
  return new ChatAnthropic({ model: 'claude-3-5-sonnet-latest' })
}

const llm = createModel(process.env.LLM_PROVIDER as 'openai' | 'anthropic')

// 统一调用方法:invoke
const result = await llm.invoke('用户说:上周买的耳机坏了,请识别意图')

结论:LangChain 把“多厂商 SDK 适配”变成可替换层,而不是业务层。


3.2 Prompt 工程层

不用 LangChain(字符串拼接,易散乱)

// 问题:提示词散落,变量注入靠手拼,容易漏字段/格式不一致
const prompt = `
你是售后助手。
规则:先识别意图,再抽取订单号/购买时间/故障描述。
用户输入:${userText}
`

用 LangChain(模板化管理)

import { ChatPromptTemplate } from '@langchain/core/prompts'

// 目的:把系统规则和变量输入显式化,便于复用/评审/版本管理
const classifyPrompt = ChatPromptTemplate.fromMessages([
  ['system', '你是售后助手。先识别意图,再抽取槽位。'],
  ['human', '用户输入:{input}'],
])

const formatted = await classifyPrompt.invoke({ input: '上周买的耳机坏了' })

3.3 输出控制层(结构化输出)

不用 LangChain(手动 JSON 解析 + 手动兜底)

const raw = await callModelWithoutLangChain('openai', prompt)

let data: any
try {
  // 手动解析,遇到“半 JSON”时很脆弱
  data = JSON.parse(raw)
} catch {
  // 你还得手写重试或修复逻辑
  throw new Error('模型输出不是合法 JSON')
}

用 LangChain(Schema 约束输出)

import { z } from 'zod'

// 目的:让模型输出契约化,减少后续流程判断混乱
const SlotSchema = z.object({
  intent: z.enum(['after_sales', 'other']),
  orderId: z.string().optional(),
  buyDate: z.string().optional(),
  issue: z.string().optional(),
  inWarranty: z.boolean().optional(),
})

const structuredModel = llm.withStructuredOutput(SlotSchema)
const slots = await structuredModel.invoke('上周买的耳机坏了')

3.4 工具调用层(Tools)

不用 LangChain(你自己维护“工具协议”)

// 问题:调用哪个工具、参数是否合法、失败如何处理,全部手写
const tools = {
  queryOrder: async (orderId: string) => omsApi.getOrder(orderId),
  createTicket: async (payload: { orderId: string; policy: string }) =>
    ticketApi.create(payload),
}

if (slots.orderId) {
  const order = await tools.queryOrder(slots.orderId)
  // ...
}

用 LangChain(统一工具定义 + 参数 Schema)

import { tool } from '@langchain/core/tools'
import { z } from 'zod'

// 目的:统一工具描述和入参契约,便于 Agent 自动调用与校验
const queryOrderTool = tool(
  async ({ orderId }) => {
    return omsApi.getOrder(orderId)
  },
  {
    name: 'query_order',
    description: '通过订单号查询订单详情',
    schema: z.object({
      orderId: z.string().describe('用户订单号'),
    }),
  },
)

const createTicketTool = tool(
  async ({ orderId, policy }) => {
    return ticketApi.create({ orderId, policy })
  },
  {
    name: 'create_after_sales_ticket',
    description: '创建售后工单',
    schema: z.object({
      orderId: z.string(),
      policy: z.enum(['refund', 'replace', 'repair']),
    }),
  },
)

3.5 RAG 数据接入层

3.5.1 不用 LangChain(手拼版,对比用)

import fs from 'node:fs/promises'
import OpenAI from 'openai'
import { Pool } from 'pg'

// 说明:
// 1) 这里演示“不用 LangChain”时你需要自己写的核心步骤
// 2) 为了对比清晰,向量库示例用 pgvector(SQL 手写)
// 3) 真实项目还要补更多细节:重试、限流、批量写入、异常恢复、监控等

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
const db = new Pool({ connectionString: process.env.PG_DSN })

// 手写切分函数:需要你自己维护 chunkSize / overlap 规则
function splitText(text: string, chunkSize = 500, overlap = 80): string[] {
  const chunks: string[] = []
  let start = 0
  while (start < text.length) {
    const end = Math.min(start + chunkSize, text.length)
    chunks.push(text.slice(start, end))
    if (end === text.length) break
    start = end - overlap
  }
  return chunks
}

async function buildIndexWithoutLangChain() {
  // 1) 自己读文档
  const policyText = await fs.readFile('./knowledge/after-sales-policy.txt', 'utf8')

  // 2) 自己切分
  const chunks = splitText(policyText, 500, 80)

  // 3) 自己调用 embedding 接口并逐条入库
  for (const chunk of chunks) {
    const emb = await openai.embeddings.create({
      model: 'text-embedding-3-small',
      input: chunk,
    })

    const vector = emb.data[0].embedding

    // 注意:SQL / 向量格式 / 维度校验都要你自己维护
    await db.query(
      `INSERT INTO policy_chunks(content, embedding)
       VALUES ($1, $2::vector)`,
      [chunk, `[${vector.join(',')}]`],
    )
  }
}

async function answerWithRagWithoutLangChain(question: string) {
  // 4) 自己把问题转 embedding
  const qEmb = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: question,
  })
  const qVector = qEmb.data[0].embedding

  // 5) 自己写向量检索 SQL(top-k)
  const topK = await db.query(
    `SELECT content
     FROM policy_chunks
     ORDER BY embedding <-> $1::vector
     LIMIT 4`,
    [`[${qVector.join(',')}]`],
  )

  const context = topK.rows.map((r) => r.content).join('\n\n')

  // 6) 自己拼 prompt 并调 chat 接口
  const completion = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [
      {
        role: 'system',
        content: '你是售后客服助手,只能基于给定政策回答。',
      },
      {
        role: 'user',
        content: `政策上下文:\n${context}\n\n用户问题:\n${question}`,
      },
    ],
  })

  return completion.choices[0]?.message?.content ?? ''
}

3.5.2 用 LangChain(同样目标,代码更聚焦)

import { TextLoader } from 'langchain/document_loaders/fs/text'
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
import { OpenAIEmbeddings, ChatOpenAI } from '@langchain/openai'
import { MemoryVectorStore } from 'langchain/vectorstores/memory'
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { RunnableSequence } from '@langchain/core/runnables'

// 1) 加载政策文档(示例:本地售后政策文件)
const loader = new TextLoader('./knowledge/after-sales-policy.txt')
const docs = await loader.load()

// 2) 切分文档(避免 chunk 太大导致检索噪声)
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 500,
  chunkOverlap: 80,
})
const chunks = await splitter.splitDocuments(docs)

// 3) 向量化 + 建索引
const embeddings = new OpenAIEmbeddings({
  model: 'text-embedding-3-small',
})
const vectorStore = await MemoryVectorStore.fromDocuments(chunks, embeddings)

// 4) 构建检索器(每次取最相关的 4 段)
const retriever = vectorStore.asRetriever(4)

// 5) 定义“检索增强回答”链路(RAG)
const llm = new ChatOpenAI({ model: 'gpt-4o-mini' })
const ragPrompt = ChatPromptTemplate.fromTemplate(`
你是售后客服助手,只能基于给定政策回答。

政策上下文:
{context}

用户问题:
{question}

请输出:
1) 命中政策(7天退/15天换/质保修)
2) 判断依据(引用上下文要点)
3) 下一步动作(补信息/建工单/转人工)
`)

const ragChain = RunnableSequence.from([
  // 先检索政策片段
  async (input: { question: string }) => {
    const retrieved = await retriever.invoke(input.question)
    const context = retrieved.map((d) => d.pageContent).join('\n\n')
    return { question: input.question, context }
  },
  // 再让模型基于检索内容生成答案
  ragPrompt,
  llm,
])

// 6) 运行示例:耳机坏了场景
const result = await ragChain.invoke({
  question: '上周买的耳机坏了,买了10天,应该走退货还是换货?',
})

console.log(result.content)

对比结论:

  • 不用 LangChain:你得手写文档处理、切分、向量入库、检索 SQL、Prompt 拼接和调用链拼装。
  • 用 LangChain:这些步骤仍然存在,但通过标准组件表达,代码更短、迁移成本更低、复用更容易。

3.6 检索增强策略层(不一定是重代码)

3.6.1 先把过程讲清楚(耳机坏了场景)

用户原话:上周买的耳机坏了

这句话直接检索,往往会“召回不全”或“召回太泛”。
所以常见增强流程是 4 步。下面逐点展开(每点都写清楚输入/处理/输出):

第 1 点:Query Rewrite(先优化检索查询)

你的理解“优化提示词”最接近这一步。
但这里优化的不是“最终回答用户的提示词”,而是“给检索系统的查询字符串”。

数据流转(字段级):

输入对象:
{
  "question": "上周买的耳机坏了"
}

-> 进入 rewritePrompt(给改写模型的提示词)
-> LLM 输出 rewritten_query

输出对象:
{
  "question": "上周买的耳机坏了",
  "rewritten_query": "耳机 售后 故障 退换修 7天无理由 15天换货 质保 订单信息"
}
flowchart LR
  A["输入对象 question\n上周买的耳机坏了"] --> B["rewritePrompt\n检索查询改写提示词"]
  B --> C["LLM 改写"]
  C --> D["输出对象\nquestion + rewritten_query"]
  D --> E["下一步\nHybrid Search 仅使用 rewritten_query"]

关键点:

  • question 是原始用户语句,面向人类表达。
  • rewritten_query 是面向检索系统的表达,专门加入政策锚点词。
  • 下一步(Hybrid Search)只吃 rewritten_query,不用原始口语句子。

为什么更好:

  • 原始问题关键词稀疏,容易漏召回。
  • 改写后召回目标更明确,尤其是规则词(7天/15天/质保)。

第 2 点:Hybrid Search(三路并行:关键词 + 向量 + 图检索)

只用一种检索会偏科:

  • 只关键词:容易漏掉语义相近说法(“坏了” vs “性能故障”)
  • 只向量:可能抓到语义相关但规则词不精确的文档
  • 只图检索:当实体抽取不稳时,容易漏召回

数据流转(字段级):

输入对象:
{
  "rewritten_query": "耳机 售后 故障 退换修 7天无理由 15天换货 质保 订单信息"
}

分支A(关键词检索)输出 keyword_hits:
[
  { "id": "p2", "content": "15天内性能故障可换货" },
  { "id": "p1", "content": "7天无理由退货条件" }
]

分支B(向量检索)输出 vector_hits:
[
  { "id": "p5", "content": "耳机功能异常售后处理" },
  { "id": "p3", "content": "超过15天走质保维修" }
]

分支C(图检索)输出 graph_hits:
[
  { "id": "p2", "content": "耳机 -> 购买10天 -> 15天内故障可换货" },
  { "id": "p3", "content": "耳机 -> 超过15天 -> 质保维修" }
]

合并去重输出 candidates:
{
  "candidates": [p2, p1, p5, p3, ...]
}
flowchart LR
  A["输入 rewritten_query"] --> B1["分支A 关键词检索"]
  A --> C1["分支B 向量检索"]
  A --> G1["分支C 图检索"]

  B1 --> B2["Elasticsearch / OpenSearch\n(倒排索引 + BM25)"]
  B2 --> B3["关键词检索结果 keyword_hits"]

  C1 --> C2["Embedding 模型\n(把 query 转向量)"]
  C2 --> C3["向量数据库\n(Vector DB)"]
  C3 --> C4["向量检索结果 vector_hits"]

  G1 --> G2["实体/关系抽取\n(商品、时效、故障类型)"]
  G2 --> G3["图数据库\n(Neo4j / Neptune)"]
  G3 --> G4["图检索结果 graph_hits"]

  B3 --> D["合并去重 merge"]
  C4 --> D
  G4 --> D
  D --> E["输出 candidates"]
  E --> F["下一步 Re-rank"]

  KB["政策语料库"] --> B2
  KB --> C3
  KB --> G3

关键点:

  • keyword_hits 更擅长抓“规则硬词”(7天、15天、质保)。
  • vector_hits 更擅长抓“语义近义”(坏了=故障=功能异常)。
  • graph_hits 更擅长抓“关系约束”(耳机 + 购买10天 + 故障 => 15天换货)。
  • candidates 是下一步 Re-rank 的输入,不直接给用户。
  • 在生产里,关键词检索这条链路通常落在 Elasticsearch / OpenSearch。
  • 在生产里,图检索通常落在 Neo4j / Neptune 等图数据库。

为什么更好:

  • 召回更完整:既不漏规则条款,也不漏语义相关条款,还能保留关键关系链。
  • 给 Re-rank 提供更高质量候选池。

图检索的优势(相对向量检索):

  • 关系可解释:可以明确展示“实体 -> 关系 -> 规则”的证据链。
  • 多跳能力强:适合“商品类别 -> 售后策略 -> 特殊例外”这类跨节点推理。
  • 规则约束更稳:对“时效、类目、故障类型”这类结构化条件更友好。

什么时候并行,什么时候串行:

  • 并行(默认推荐):用户输入口语化、实体不稳定、希望尽量不漏召回时。
  • 串行(图先行再向量):实体抽取非常稳定,且规则强依赖关系链时。
  • 串行(向量先行再图):语料很大、先用向量粗召回降成本,再用图做精筛时。

第 3 点:Re-rank(二次重排)

你这个理解可以这样说得更准确:

  • 是的,这一步会用到 LLM。
  • 但它不是“直接优化最终答案”,而是“先从很多检索结果里选出最该看的证据”。

通俗比喻:

  • Hybrid 检索像一次海投,先拿到很多候选简历。
  • Re-rank 像面试官先筛选 Top3 最匹配简历。
  • 最后回答模型只看 Top3,再给用户结论。

数据流转(字段级):

输入对象:
{
  "question": "上周买的耳机坏了,买了10天,应该退货还是换货?",
  "candidates": [doc1, doc2, ... docN]   // N 通常较大(如 20~50)
}

-> 进入 rerankPrompt(要求只按“能否回答当前问题”排序)
-> LLM 输出 ranked_ids

输出对象:
{
  "ranked_ids": ["p2", "p3", "p4", ...],
  "top_docs": [p2, p3, p4]               // 只截取 TopK 进入下一步
}
flowchart LR
  A["输入 question + candidates(N条)"] --> B["rerankPrompt\n定义排序标准"]
  B --> C["LLM Re-ranker\n输出 ranked_ids"]
  C --> D["按 ranked_ids 映射文档"]
  D --> E["截取 TopK docs"]
  E --> F["下一步 生成答案"]

示例:

用户问题:
上周买的耳机坏了,买了10天,应该退货还是换货?

候选文档(重排前):
1. 手机配件7天退货规则
2. 耳机15天内性能故障可换货
3. 耳机超过15天走维修
4. 订单号缺失补充流程

重排后 Top3:
1. 耳机15天内性能故障可换货
2. 耳机超过15天走维修
3. 订单号缺失补充流程

为什么更好:

  • 把“最能回答当前问题”的条款放前面,减少无关噪声。
  • 降低把错误/无关文档喂给回答模型的概率。

第 4 点:生成答案(只喂 TopK 上下文)

最后一步才是“面向用户生成答案”。
关键是:不要把全部候选都喂给模型,而是只喂重排后的 TopK(通常 3~5 条)。

示例:

输入给回答模型的上下文:
- 耳机15天内性能故障可换货
- 超过15天走质保维修
- 订单信息缺失需补全

输出给用户:
你购买约10天,若确认为性能故障,优先走“15天内换货”。
请补充订单号后我为你创建售后工单。
flowchart LR
  A["输入 question + top_docs(TopK)"] --> B["answerPrompt\n要求按政策输出结论"]
  B --> C["LLM 回答模型"]
  C --> D["结构化结果\npolicy + reason + next_action"]
  D --> E["面向用户回复\n+ 是否建工单/补信息/转人工"]

为什么更好:

  • 减少“上下文污染”,降低模型一本正经答错的概率。

总结一句:

  • 第 1 步优化“怎么搜”
  • 第 2、3 步优化“搜什么”
  • 第 4 步优化“怎么答”

3.6.2 用 LangChain 的实现代码(基于 3.5 的 llm/retriever)

import { ChatPromptTemplate } from '@langchain/core/prompts'
import { StringOutputParser } from '@langchain/core/output_parsers'
import { z } from 'zod'

/**
 * 约定:
 * - llm: 来自 3.5 的 ChatOpenAI 实例
 * - retriever: 来自 3.5 的向量检索器(vectorStore.asRetriever)
 * - POLICY_DOCS: 本地政策文档数组(用于关键词检索演示)
 */

// 1) Query Rewrite:先把用户原话改写成“检索友好”的查询
const rewritePrompt = ChatPromptTemplate.fromTemplate(`
你是检索查询改写器。请将用户问题改写成更适合检索政策文档的查询。
要求:保留售后关键条件词(7天退、15天换、质保、故障、订单信息)。
只输出改写后的查询,不要解释。

用户问题: {question}
`)
const rewriteChain = rewritePrompt.pipe(llm).pipe(new StringOutputParser())

// 2) 关键词检索(Hybrid 的 keyword 分支)
function keywordRetrieve(query: string, topK = 8) {
  const tokens = new Set(
    query.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, ' ').split(/\s+/).filter(Boolean),
  )

  return POLICY_DOCS
    .map((doc) => {
      const dt = new Set(
        doc.content.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, ' ').split(/\s+/).filter(Boolean),
      )
      let overlap = 0
      tokens.forEach((t) => {
        if (dt.has(t)) overlap++
      })
      return { doc, score: overlap }
    })
    .sort((a, b) => b.score - a.score)
    .filter((x) => x.score > 0)
    .slice(0, topK)
    .map((x) => x.doc)
}

// 3) Re-rank:对候选文档做二次排序(结构化输出 rankedIds)
const rerankSchema = z.object({
  rankedIds: z.array(z.string()),
})
const rerankModel = llm.withStructuredOutput(rerankSchema)
const rerankPrompt = ChatPromptTemplate.fromTemplate(`
你是检索重排器。请根据用户问题对候选文档按相关性从高到低排序。
只返回 JSON,格式为:{"rankedIds":["id1","id2"]}。

用户问题:
{question}

候选文档(JSON):
{candidates}
`)

// 4) 把三步组合成“检索增强函数”
async function enhancedRetrieve(question: string) {
  // 4.1 查询改写
  const rewrittenQuery = await rewriteChain.invoke({ question })

  // 4.2 向量检索 + 关键词检索(Hybrid)
  const vectorHits = await retriever.invoke(rewrittenQuery)
  const vectorDocs = vectorHits.map((d) => ({
    id: String(d.metadata?.id ?? ''),
    content: d.pageContent,
  }))
  const keywordDocs = keywordRetrieve(rewrittenQuery, 8)

  // 4.3 合并去重
  const mergedMap = new Map<string, { id: string; content: string }>()
  ;[...vectorDocs, ...keywordDocs].forEach((d) => mergedMap.set(d.id, d))
  const candidates = Array.from(mergedMap.values())

  // 4.4 二次重排
  const rerankInput = await rerankPrompt.invoke({
    question,
    candidates: JSON.stringify(candidates, null, 2),
  })
  const { rankedIds } = await rerankModel.invoke(rerankInput)
  const byId = new Map(candidates.map((d) => [d.id, d]))
  const topDocs = rankedIds.map((id) => byId.get(id)).filter(Boolean).slice(0, 3)

  return {
    rewrittenQuery,
    candidates,
    topDocs,
  }
}

// 5) 调用示例
const retrieved = await enhancedRetrieve('上周买的耳机坏了,买了10天,应该退货还是换货?')
console.log('改写查询:', retrieved.rewrittenQuery)
console.log('重排后文档:', retrieved.topDocs)

3.6.3 不用 LangChain 时你要自己做什么

不用 LangChain 也能实现同样流程,但你要手写:

  • 两次以上模型调用协议(改写、重排、回答)
  • 不同阶段的 JSON 解析与容错
  • 向量检索 + 关键词检索 + 合并去重
  • 各步骤的输入输出传递和运行日志

所以 3.6 的重点不是“有没有这三步”,而是“是否能把三步做成稳定、可复用、可调试的模块”。


3.7 链式编排层(LCEL / Runnable)

先说“链式编排解决了什么问题”(总结版):

  • 解决步骤散落:把“抽取 -> 检索 -> 判定 -> 执行动作”收敛成一条可读流程。
  • 解决数据传递混乱:明确每一步输入输出,减少字段丢失和隐式依赖。
  • 解决治理逻辑分散:重试、日志、超时、fallback 可以挂在链上统一管理。
  • 解决复用困难:把步骤做成可插拔模块,便于跨场景复用(售后、质检、审计)。

下面用“耳机坏了”同样业务,换一个更贴近真实工程的写法(pipe 风格):

import { ChatPromptTemplate } from '@langchain/core/prompts'
import { RunnableLambda } from '@langchain/core/runnables'

// Step 1: 统一输入格式(前端/接口层传什么都先归一化)
const normalizeInput = RunnableLambda.from(
  async (input: { userText: string; threadId?: string }) => ({
    userText: input.userText,
    threadId: input.threadId ?? 'anonymous-thread',
  }),
)

// Step 2: 槽位抽取(从用户话术中抽取订单号、购买天数、故障描述)
const extractPrompt = ChatPromptTemplate.fromMessages([
  ['system', '抽取售后槽位'],
  ['human', '{input}'],
])
const extractStep = RunnableLambda.from(
  async (state: { userText: string; threadId: string }) => {
    const slots = await extractPrompt.pipe(structuredModel).invoke({
      input: state.userText,
    })
    return { ...state, slots }
  },
)

// Step 3: 政策检索(从 RAG 检索“7天退/15天换/质保修”)
const retrieveStep = RunnableLambda.from(
  async (state: {
    userText: string
    threadId: string
    slots: { orderId?: string; buyDays?: number; issue?: string }
  }) => {
    const docs = await retriever.invoke(state.userText)
    return { ...state, docs }
  },
)

// Step 4: 决策判定(refund / replace / repair)
const decideStep = RunnableLambda.from(
  async (state: {
    userText: string
    threadId: string
    slots: { orderId?: string; buyDays?: number; issue?: string }
    docs: string[]
  }) => {
    const decision = await decidePolicy(state.slots, state.docs)
    return { ...state, decision }
  },
)

// Step 5: 输出动作(给用户回复 + 是否建工单)
const buildReplyStep = RunnableLambda.from(
  async (state: {
    userText: string
    threadId: string
    slots: { orderId?: string; buyDays?: number; issue?: string }
    docs: string[]
    decision: 'refund' | 'replace' | 'repair'
  }) => {
    const reply = await buildAfterSalesReply(state.decision, state.slots)
    return { ...state, reply }
  },
)

// 链式编排:前一步输出作为后一步输入(你提到的核心点)
const afterSalesChain = normalizeInput
  .pipe(extractStep)
  .pipe(retrieveStep)
  .pipe(decideStep)
  .pipe(buildReplyStep)

// 运行示例(耳机坏了)
const result = await afterSalesChain.invoke({
  userText: '上周买的耳机坏了,买了10天',
  threadId: 't_1001',
})

console.log(result.decision) // 例如 replace
console.log(result.reply) // 给用户的话术

3.7.1 用 RxJS 心智模型理解链式编排

你的类比非常好,LCEL/Runnable 可以近似理解为:

  • RunnableSequence.from([...]) 类似 pipe(...)
  • 每个 step 是一个“接收输入 -> 返回新对象”的函数
  • 数据在步骤间流动,前一步输出就是后一步输入

3.7.1.1 你提到的 pipe vs RunnableSequence 到底什么关系

你记得没错,TS 里的 LCEL 最常见写法是 .pipe()
RunnableSequence 不是另一套东西,而是顺序链的显式构造方式。

可以理解为:

  • pipe 是你常写的“链式语法”
  • RunnableSequence 是这个顺序链的“对象表达”

等价示例:

// 写法A:LCEL 常见 pipe 风格
const chainByPipe = prompt.pipe(model).pipe(parser)

// 写法B:显式 RunnableSequence
const chainBySequence = RunnableSequence.from([prompt, model, parser])

两者都可以执行:

await chainByPipe.invoke(input)
await chainBySequence.invoke(input)

什么时候更偏向用哪种:

  • 日常开发:pipe 更直观,写起来快。
  • 动态拼装步骤(按条件增删步骤):RunnableSequence.from([...]) 更方便维护。

3.7.2 你问的 4 个点(结合耳机售后)

1) 输入输出固定,为什么更容易测试和换实现

本质是给每个步骤定义“数据契约”。例如:

/**
 * 第一步(抽取)后的输出:
 * - text: 用户原话,例如“上周买的耳机坏了”
 * - slots: 从原话里抽到的结构化字段
 *   - orderId: 订单号(很多时候第一轮拿不到)
 *   - buyDays: 购买天数(例如 10,后续决定 7天退/15天换)
 *   - issue: 故障描述(例如“左耳无声”)
 */
type ExtractOut = {
  text: string
  slots: { orderId?: string; buyDays?: number; issue?: string }
}

/**
 * 第二步(检索)后的输出:
 * - 继承 ExtractOut(前一步数据不能丢)
 * - docs: 检索到的政策片段,例如:
 *   - “15天内性能故障可换货”
 *   - “超过15天走质保维修”
 */
type RetrieveOut = ExtractOut & { docs: string[] }

/**
 * 第三步(决策)后的输出:
 * - 在前两步基础上新增最终策略 decision
 * - decision 是给后续动作节点用的“明确指令”
 *   - refund: 退货
 *   - replace: 换货
 *   - repair: 维修
 */
type DecideOut = RetrieveOut & { decision: 'refund' | 'replace' | 'repair' }

好处:

  • 测试容易:测 decideStep 时,可直接喂一个假的 RetrieveOut,不必真的跑前两步。
  • 换实现容易:把“向量检索”换成“三路检索”,只要输出仍是 docs,后面步骤不用改。

2) 复用怎么发生

复用的不是整条大链,而是“子链/步骤”。

const extractStep = async (input: { text: string }) => ({ ...input, slots: {/* ... */} })
const retrieveStep = async (input: { slots: any }) => ({ ...input, docs: [/* ... */] })

// 场景A:售后决策
const chainA = RunnableSequence.from([extractStep, retrieveStep, decideStep])

// 场景B:质检审计(复用前两步)
const chainB = RunnableSequence.from([extractStep, retrieveStep, auditStep])

3) 运行治理统一(真实场景)

场景:工单系统偶发超时,或者检索服务偶尔 500。

不用统一链路时:你要在每个函数里分散写重试、超时、日志。
用链路时:治理策略集中挂载,避免散落。

const chain = RunnableSequence.from([extractStep, retrieveStep, decideStep, createTicketStep])
  .withRetry({ stopAfterAttempt: 3 }) // 统一重试策略
  .withConfig({ runName: 'after-sales-chain' }) // 统一观测标识

4) 为什么说“容易演进到 LangGraph”

后续你把流程升级为“分支/回路/人工审批”时,LangChain 子链可直接作为 LangGraph 节点能力复用:

// 伪代码:LangGraph node 里复用 LangChain chain
async function policyNode(state: { text: string }) {
  const result = await afterSalesChain.invoke({ text: state.text })
  return { ...state, decision: result.decision }
}

这意味着不是推翻重写,而是“把已有能力拼进更复杂流程”。

3.7.3 回到你的原问题:不用 LangChain 直接写行不行

行,完全可以。
你的判断也对:在小项目里体感可能只是“代码更简洁”。

不用 LangChain(手写编排)

// 耳机售后链路:抽取 -> 检索 -> 决策 -> 建工单
// 问题:重试/日志/错误处理会散在每一步里
async function handleAfterSalesWithoutLangChain(text: string) {
  let slots
  try {
    slots = await extractSlots(text) // 你自己调模型
  } catch (e) {
    logger.error('extractSlots failed', e)
    throw e
  }

  let docs
  try {
    docs = await retrievePolicyDocs(text) // 你自己拼检索
  } catch (e) {
    logger.error('retrievePolicyDocs failed', e)
    // 手写重试逻辑(只在这一段生效,其他段要重复写)
    docs = await retrievePolicyDocs(text)
  }

  const decision = await decidePolicy(slots, docs)

  try {
    return await createTicket(decision)
  } catch (e) {
    logger.error('createTicket failed', e)
    // 降级策略也要你自己在这里写
    return { status: 'fallback_to_human' }
  }
}

用 LangChain(链式编排)

// 同样的业务步骤,但用统一链路表达
const afterSalesChain = RunnableSequence.from([
  extractStep,
  retrieveStep,
  decideStep,
  createTicketStep,
])
  .withRetry({ stopAfterAttempt: 3 }) // 统一重试策略
  .withConfig({ runName: 'after-sales-chain' }) // 统一观测标识

const result = await afterSalesChain.invoke({ text: '上周买的耳机坏了' })

不用的后果(真实项目会放大)

  • 步骤一多,重试/日志/降级逻辑会分散在多个函数中,维护成本上升。
  • 新增一个步骤时,容易漏掉治理逻辑(比如某一步没加超时或没打日志)。
  • 排障时要跨多个函数追踪上下文,问题定位时间更长。
  • 迁移到更复杂流程(分支、回路、人工审批)时,改造面更大。

3.8 多轮上下文与记忆层

先讲一个真实场景(什么叫多轮对话):

第1轮 用户: 上周买的耳机坏了
第1轮 系统: 请提供订单号

第2轮 用户: 订单号 123
第2轮 系统: 请问购买了几天,故障现象是什么?

第3轮 用户: 买了10天,左耳没声音
第3轮 系统: 命中“15天内性能故障可换货”,我先为你创建换货工单

这就是多轮:用户不会一次把信息说全,系统要边问边补齐状态

3.8.1 用“回路”来理解多轮(这就是 LangGraph 的回边)

你说得完全对,这里本质就是回路:

  • extract(抽取信息)
  • 如果缺字段 -> clarify(追问)
  • 用户回答后再回到 extract

也就是:extract -> clarify -> extract,直到字段补齐。

flowchart LR
  A["用户输入\n上周买的耳机坏了"] --> B["extract\n抽取槽位"]
  B --> C{"字段齐了吗?\norderId + buyDays + issue"}
  C -- "否" --> D["clarify\n追问缺失字段"]
  D --> A2["用户补充\n订单号/购买天数/故障"]
  A2 --> B
  C -- "是" --> E["决策\nrefund/replace/repair"]

一眼看状态怎么累积:

第1轮后: { issue: "耳机坏了", orderId: null, buyDays: null }
第2轮后: { issue: "耳机坏了", orderId: "123", buyDays: null }
第3轮后: { issue: "左耳没声音", orderId: "123", buyDays: 10 }
-> 字段补齐,进入退/换/修判定

3.8.2 极简代码(不讲框架细节,只看回路)

type State = {
  orderId?: string
  buyDays?: number
  issue?: string
}

function isComplete(s: State) {
  return !!(s.orderId && s.buyDays !== undefined && s.issue)
}

// 每轮用户发言都会调用一次(在线客服常见模式)
async function handleTurn(sessionId: string, userText: string) {
  // 1) 读历史状态(上轮累积结果)
  const state = (await loadState(sessionId)) ?? {}

  // 2) 从本轮话术里继续抽取字段并合并
  const more = await extractSlots(userText) // 例如抽出 orderId/buyDays/issue
  const merged = { ...state, ...more }

  // 3) 字段不全 -> 追问(回路继续)
  if (!isComplete(merged)) {
    await saveState(sessionId, merged)
    return askMissingField(merged) // 例如“请提供订单号”
  }

  // 4) 字段齐全 -> 执行决策
  const decision = await decidePolicy(merged)
  await saveState(sessionId, { ...merged, decision })
  return buildReply(decision)
}

这段代码对应的就是上面的回路图。
LangChain/LangGraph 的价值,是把这个模式做成更标准、可复用、可观测。

3.8.3 记忆层到底是什么(你这个理解是对的)

你说的“通过不停累加多轮上下文”,本质就是记忆层在工作。
但在工程里通常会拆成 3 类记忆:

  1. state(流程状态记忆)
    用于业务决策字段,例如:orderId/buyDays/issue/decision
    目标是“流程能继续跑”。

  2. history(对话短期记忆)
    保存最近几轮对话原文。
    目标是“说话不失忆、语义连贯”。

  3. profile(长期记忆,可选)
    保存跨会话的用户偏好或长期信息,例如“常用收货地址、偏好联系时间”。
    目标是“跨会话个性化”。

flowchart LR
  A["新一轮用户输入"] --> B["读取记忆\nstate + history + profile"]
  B --> C["模型与规则处理"]
  C --> D["输出本轮回复/动作"]
  D --> E["写回记忆\n更新 state/history/profile"]
  E --> A2["下一轮继续"]

可以简单理解为:

  • 回路解决“流程怎么走”
  • 记忆层解决“上轮信息怎么留到下轮”

3.8.4 这一层解决的核心问题

  • 避免重复提问:系统知道“订单号已经给过了”。
  • 避免上下文断裂:第 3 轮还能记得第 1 轮的“耳机坏了”。
  • 降低误判:决策基于累积状态,而不是单轮片段信息。

3.8.5 不做会怎样(常见后果)

  • 每轮都像新会话:反复问订单号,用户体验差。
  • 字段易丢失:第 1 轮提到的故障信息在第 3 轮丢了。
  • 决策不稳定:同一用户不同轮次得到冲突结论。

3.8.6 LangChain vs LangGraph:记忆与检查点(Checkpoint)各管什么

这是最容易混淆的点,可以这样记:

  • LangChain 更偏“对话记忆能力层”(让模型记得上下文)。
  • LangGraph 更偏“流程状态与恢复层”(让流程能中断后继续)。
维度LangChainLangGraph
主要目标让模型在多轮里“记得说过什么”让流程在多节点里“记得跑到哪一步”
典型数据history(消息历史)state + nextNode + status
关注点语义连贯、减少重复提问可中断、可恢复、可审计
中断恢复不是核心强项核心能力(配 checkpointer)
典型标识sessionIdthreadId

一句话:

  • LangChain 记“聊天上下文”。
  • LangGraph 记“流程进度和状态快照”。

A) LangChain 在记忆层的典型做法(会话历史)

// 伪代码:按 sessionId 读取/写入历史,让下一轮带上前文
const history = await loadChatHistory(sessionId) // 例如 Redis/DB
const response = await historyAwarePrompt.pipe(llm).invoke({
  history,           // 上下文记忆
  input: userText,   // 本轮输入
})
await appendChatHistory(sessionId, userText, String(response.content))

它解决的是:用户第 2 轮说“订单号是123”,第 3 轮系统还能记住第 1 轮“耳机坏了”。

B) LangGraph 在检查点层的典型做法(流程恢复)

// 伪代码:按 threadId 保存/恢复流程执行位置
// state 里可放 slots、decision、审批结果等
await saveCheckpoint({
  threadId: 't_1001',
  nextNode: 'approval',        // 当前停在哪个节点
  state: { orderId: '123', buyDays: 10, issue: '左耳没声音' },
  status: 'WAITING_APPROVAL',
})

// 2小时后审批回调
const cp = await loadCheckpoint('t_1001')
cp.state.approved = true
cp.status = 'RUNNING'
await saveCheckpoint(cp)
await resumeFromNode(cp.nextNode, cp.state) // 从中断点继续,不重跑全流程

它解决的是:人工审批/服务重启后,流程还能从原步骤继续。

C) 在你的耳机售后场景,建议怎么搭配

  • 只多轮对话(简单问答):LangChain 记忆层通常够用。
  • 涉及审批、回路、恢复、审计:要加 LangGraph checkpoint。
  • 最常见生产方案:LangChain 管“会话记忆”,LangGraph 管“流程记忆(检查点)”。

3.8.7 上下文召回、上下文压缩、上下文爆炸:三者是什么

先用通俗定义:

  • 上下文召回:从“很多历史信息”里只拿当前问题真正需要的部分。
  • 上下文压缩:把长上下文压成短摘要/关键槽位,减少 token 占用。
  • 上下文爆炸:上下文越来越长,导致成本高、延迟高、噪声高、回答变差。

在耳机售后里的典型表现:

  • 第 1~8 轮混入很多寒暄和无关描述。
  • 真正关键的只有:orderId=123buyDays=10issue=左耳无声
  • 如果不召回/压缩,模型会被无关上下文“淹没”。

LangChain / LangGraph / Deep Agents 怎么应对

框架主要应对点常见方案成熟度(落地体感)
LangChain召回 + 压缩(能力层)历史裁剪、摘要记忆、检索式记忆、Contextual Compression Retriever、重排高(组件成熟、组合灵活)
LangGraph爆炸治理 + 恢复(流程层)Checkpoint、状态分层(state/history/profile)、节点内摘要、只在关键节点注入上下文、中断恢复高(生产流程友好)
Deep Agents长程任务自动上下文管理自主上下文压缩、任务分解后局部上下文、阶段性摘要回写中-高(思路先进,具体落地依赖实现版本)

你可以这样记:

  • LangChain 擅长“怎么处理上下文内容”。
  • LangGraph 擅长“上下文在哪些流程节点被读取/写回/恢复”。
  • Deep Agents 更强调“长任务里自动管理上下文”的策略。

Deep Agents 这块你是否“完全不用管”

可以这样理解:

  • 对,默认有内建自动机制(压缩、摘要、offloading),你不必从零手写整套。
  • 但不等于完全不管:你仍要做策略配置和边界约束。

你仍需要决定的常见事项:

  1. 哪些信息必须保留原文(不能只留摘要)。
  2. 哪些内容允许被压缩/卸载(例如大工具输出)。
  3. 什么时候触发人工介入(摘要质量不达标、关键信息疑似丢失)。

一句话:

  • Deep Agents 是“自动化为主”。
  • 生产落地仍需“策略兜底 + 监控审计”。

实操建议(你这个项目可直接用)

  1. 先做“召回”再做“压缩”,不要一上来全量摘要。
  2. 会话里只保留关键槽位和最近 N 轮原文,其余转摘要。
  3. 在 LangGraph 节点上加 checkpoint,避免重启后重算全量上下文。
  4. 设定上下文预算(例如超过阈值就触发压缩)。
  5. 把“摘要版本号 + 生成时间”写入状态,便于审计和回放。

3.8.8 一页总结:3个框架在“上下文召回/压缩/防爆炸”上的 API 与示例

先给结论(你可以直接复述给团队):

  • LangChain:偏“内容处理层”,有较直接的记忆/摘要/裁剪能力可调用。
  • LangGraph:偏“流程状态层”,核心是 thread + checkpoint + state,压缩策略常放在节点里执行。
  • Deep Agents:偏“长任务自动管理层”,内建自动压缩/摘要/offloading,但仍要做策略边界配置。

A) LangChain(会话记忆 + 压缩/裁剪)

典型 API 方向:

  • ChatPromptTemplate + MessagesPlaceholder(把历史消息接入提示词)
  • 中间件/策略(例如摘要压缩、消息裁剪)
  • 长短期记忆接口(按 session/thread 读写)

重点说明:下面示例里的 trimOrSummarize(history) 是“示意占位函数名”,不是 LangChain 官方内置同名 API。
实际落地时,你需要:

  1. 自己实现该函数,或
  2. 用 LangChain 的现成能力(消息裁剪/摘要中间件/记忆组件)组合后封装成这个函数。
// 示例:历史接入 + 压缩策略(示意)
import { ChatPromptTemplate, MessagesPlaceholder } from '@langchain/core/prompts'

const prompt = ChatPromptTemplate.fromMessages([
  ['system', '你是售后助手,基于历史对话补全信息'],
  new MessagesPlaceholder('history'),
  ['human', '{input}'],
])

const history = await loadChatHistory(sessionId) // Redis/DB
const response = await prompt.pipe(llm).invoke({
  history: trimOrSummarize(history), // 这里放裁剪/摘要策略
  input: userText,
})
await appendChatHistory(sessionId, userText, String(response.content))

适用:多轮对话语义连贯、减少重复提问。

B) LangGraph(检查点 + 恢复 + 节点内压缩)

典型 API 方向:

  • thread_id(会话流程标识)
  • checkpointer(保存状态快照)
  • getState/getStateHistory(查看当前与历史状态)
// 示例:流程检查点与恢复(示意)
const config = { configurable: { thread_id: 'ticket_1001' } }

// 首次运行
await app.invoke({ userText: '上周买的耳机坏了' }, config)

// 查看当前状态(卡在哪)
const now = await app.getState(config)

// 人工审批后恢复
await app.invoke({ approved: true }, config) // 从已有 thread 状态继续

适用:有回路、人工审批、中断恢复、可审计流程。

C) Deep Agents(自动上下文管理)

典型 API 方向:

  • createDeepAgent(...)
  • 内建 context compression / offloading / summarization(自动触发)
  • memory / skills / runtime context 配置
// 示例:Deep Agents 创建(示意)
import { createDeepAgent } from 'deepagents'

const agent = await createDeepAgent({
  model: 'claude-sonnet-4-6',
  memory: ['/project/AGENTS.md'],
  skills: ['/skills/customer-service/'],
})

const result = await agent.invoke({
  messages: [{ role: 'user', content: '处理一个长会话售后任务' }],
})

适用:长流程任务、上下文易爆炸、希望默认自动治理。

D) 最常见落地组合(推荐)

  1. LangChain 处理“上下文内容”(召回、压缩、摘要)。
  2. LangGraph 管“流程记忆”(checkpoint 与恢复)。
  3. Deep Agents 适合需要更强自动化上下文管理的长任务场景。

3.9 可观测与评估层(LangSmith + Langfuse)

你线上一定会被问:

  • 为什么没走换货?
  • 卡在哪一步?
  • 哪个模型/哪个 prompt 导致误判?
  • 哪次发布后错误率上升?

所以可观测层的目标不是“看日志”,而是“能定位原因、能做回归评估、能持续优化”。

3.9.1 LangSmith vs Langfuse(怎么选)

维度LangSmithLangfuse
与 LangChain/LangGraph 集成深度集成,LangChain 可零改动起步(环境变量启用)集成成熟(Callback / OTel),但通常需要显式接入
Tracing 体验对 LangChain/LangGraph trace 树很友好通用 tracing 能力强,跨框架可观测也好做
Evals 评估能力原生评估与数据集闭环较完整也支持评估与打分,但实现路径更偏通用平台化
部署形态托管体验强开源/自建友好(很多团队看重这点)
适合场景LangChain/LangGraph 主栈,想快速起量多框架混用或偏好自建可观测平台

一句话建议:

  • 你当前这份文档主线是 LangChain/LangGraph,优先 LangSmith 上手最快。
  • 如果你团队强调开源自建与统一观测底座,Langfuse 是强备选。

3.9.2 LangSmith 典型 API/配置(JS/TS)

官方最常见起步方式:环境变量启用 tracing,LangChain 代码可保持不变。

export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY=<your-api-key>
export LANGSMITH_PROJECT=after-sales-agent
export OPENAI_API_KEY=<your-openai-api-key>
// 代码可保持 LangChain 常规写法,trace 自动进入 LangSmith
const result = await afterSalesChain.invoke({ text: '上周买的耳机坏了' })

可选:给链路加 tags/metadata,方便查询与对比:

await afterSalesChain.invoke(
  { text: '上周买的耳机坏了' },
  {
    tags: ['after-sales', 'headset'],
    metadata: { env: 'prod', feature: 'policy-v2' },
  },
)

3.9.3 Langfuse 典型 API/配置(JS/TS)

Langfuse 常见接入方式是 OTel + Langfuse 处理器,或对 OpenAI/LangChain 做包装。

LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_BASE_URL=https://cloud.langfuse.com
// 1) 先初始化 OTel + Langfuse span processor
import { NodeSDK } from '@opentelemetry/sdk-node'
import { LangfuseSpanProcessor } from '@langfuse/otel'

const sdk = new NodeSDK({
  spanProcessors: [new LangfuseSpanProcessor()],
})
sdk.start()
// 2) LangChain 场景:挂 callback
import { CallbackHandler } from '@langfuse/langchain'

const langfuseHandler = new CallbackHandler()
const result = await afterSalesChain.invoke(
  { text: '上周买的耳机坏了' },
  { callbacks: [langfuseHandler] },
)

3.9.4 结合耳机售后,最小落地建议

  1. 每次调用都打上 threadId/orderId/env/version 元数据。
  2. 重点追踪 4 个节点耗时:抽取、检索、重排、决策。
  3. 为“误判退换修”建立评估集,发布前后自动回归。
  4. 对人工审批链路单独做 trace 过滤视图(便于排障和审计)。

3.9.5 收费与数据安全:你最关心的结论

A) 收费(按“框架本体”与“平台服务”区分)

项目结论(简版)你该怎么理解
LangChain(开源框架)框架本体开源可用成本主要来自模型调用、向量库、基础设施,不是“买 LangChain 才能跑”
LangSmith(托管平台)有免费/付费计划,通常按功能+用量计费适合希望快速接入观测与评估的团队
Langfuse Cloud有免费/付费计划快速上云,功能开箱
Langfuse Self-Hosted可自托管(常见 OSS 路线)可把观测数据留在自己基础设施内

价格会随时间调整,落地前请以官方 pricing 页面为准。

B) 数据安全(按“数据驻留位置”看)

方案数据主要在哪里安全责任
LangSmith 托管平台云端(按你配置与服务条款)平台 + 你方共同责任(分类、脱敏、权限)
Langfuse CloudLangfuse 云端平台 + 你方共同责任
Langfuse Self-Hosted你自己的 VPC/私有云/本地机房你方主责(补丁、网络、密钥、备份、审计)

一句话:

  • 想快:托管平台(LangSmith / Langfuse Cloud)。
  • 想强数据主权:Langfuse 自托管(但运维责任更重)。

C) 合规与安全落地清单(强烈建议写进项目规范)

  1. 对 trace 输入做脱敏:手机号、邮箱、地址、身份证、支付信息默认打码。
  2. 只记录必要字段:调试够用即可,避免把原始敏感文本全量上报。
  3. 设置数据保留策略:生产与测试环境分开,过期自动清理。
  4. 做权限隔离:按项目/租户/环境分隔可见性。
  5. 为关键链路保留审计字段:threadId / orderId / version / reviewer
  6. 上线前做“红线检查”:确认不会把密钥、token、内部 URL 记入 trace。

这层不是“可选锦上添花”,而是生产质量的基础设施。


3.10 运行治理层(重试/超时/降级/缓存)

// 说明:不同版本 API 细节会有差异,这里是工程意图示例
// 目的:让调用链更稳,避免外部依赖抖动直接传递给用户
const resilientChain = afterSalesChain
  // 失败自动重试(示意)
  .withRetry({ stopAfterAttempt: 3 })
  // 统一运行配置(示意)
  .withConfig({ runName: 'after-sales-chain' })

这一层是“生产可用性”的关键,不是可有可无。


4. 用与不用 LangChain 的整体对比(耳机售后 Agent)

维度不用 LangChain用 LangChain
模型切换改多处 SDK 调用多数只改模型初始化
Prompt 管理字符串散落模板化可复用
输出稳定性手写 JSON parseSchema 化输出
工具接入协议各写各的Tool + Schema 统一
RAG 接入重复造轮子标准组件拼装
多轮会话易丢上下文历史接入更自然
排障与评估数据碎片化链路可追踪性更好
维护成本规则变多后快速上升模块化演进更稳

5. 最后一句(给团队沟通时可以直接用)

开发 Agent 一定会碰到模型、Prompt、输出、工具、RAG、上下文、治理、观测等模块。
LangChain 的价值不是替你做业务决策,而是把这些模块做成统一中间层,像 JDBC 一样屏蔽底层差异。

你把时间花在“耳机售后规则怎么判”上,而不是花在“每个厂商 SDK 都写一遍”上。