LangChain 生产级实战(八):结构化输出与语义路由 —— 让 AI 听话且精准

1. 痛点:LLM 的“话痨”属性与“偏科”问题

痛点一:解析的噩梦

当你要求 LLM “请提取简历中的姓名和电话,并以 JSON 格式返回”时,它经常会给你返回这样的东西:

1
2
3
4
5
6
好的,这是我提取的结果:
```json
{
"name": "张三",
"phone": "13800000000"
}

希望这对你有帮助!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

你的 JSON 解析器(`json.loads`)会立刻报错,因为它包含了 ```json 标记和多余的废话。以前我们只能写正则表达式来清洗,非常痛苦。

### 痛点二:全能 Prompt 的平庸
如果你试图写一个“万能 Prompt”来处理所有类型的客户咨询(退货、维修、投诉),这个 Prompt 会变得非常长且效果平庸。更好的做法是:**专人专办**。但是,怎么知道用户这句话该找哪个“专人”呢?

## 2. 解决方案一:结构化输出 (Structured Output)

从 2023 年底开始,OpenAI 等模型厂商引入了 **Function Calling (Tool Calling)** 和 **JSON Mode**。LangChain 对此进行了完美的封装,提供了 `.with_structured_output()` 方法。

### 实战:提取标准化的用户信息

我们需要使用 Python 的 `Pydantic` 库来定义我们想要的“数据模具”。

#### 环境准备

```bash
pip install langchain langchain-openai pydantic

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from typing import Optional, List
from langchain_core.pydantic_v1 import BaseModel, Field
from langchain_openai import ChatOpenAI

# 1. 定义数据结构 (Schema)
# 这就是我们的"模具"。LLM 会尽全力把回答填进这个模具里。
class UserInfo(BaseModel):
name: str = Field(description="用户的全名")
age: Optional[int] = Field(description="用户的年龄,如果未提及则为 None")
interests: List[str] = Field(description="用户的兴趣爱好列表")
is_student: bool = Field(description="用户是否是学生")

# 2. 初始化模型
# 建议使用支持 Tool Calling 的强模型,如 gpt-3.5-turbo 或 gpt-4o
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)

# 3. 绑定结构化输出
# 这行代码是魔法所在。它不再返回 AIMessage (文本),而是直接返回 UserInfo 对象。
structured_llm = llm.with_structured_output(UserInfo)

# 4. 测试
text_input = "我是李四,今年20岁,在北大读大二。平时喜欢打篮球和看科幻小说。"

print(f"--- 输入文本: {text_input} ---")
result = structured_llm.invoke(text_input)

# 5. 验证结果
print("\n--- 结构化输出结果 (UserInfo 对象) ---")
print(f"Name: {result.name} (Type: {type(result.name)})")
print(f"Age: {result.age} (Type: {type(result.age)})")
print(f"Interests: {result.interests} (Type: {type(result.interests)})")
print(f"Is Student: {result.is_student} (Type: {type(result.is_student)})")

# 我们可以直接导出为字典给前端
print("\n--- 导出的 JSON ---")
print(result.dict())

深度解析

.with_structured_output() 在底层其实是利用了 OpenAI 的 Tool Calling 机制。它把 UserInfo 转换成了 JSON Schema 传给模型,并告诉模型:“你必须调用这个 Tool”。模型生成参数后,LangChain 自动截获并转换回 Pydantic 对象。 这意味着:0 正则解析,100% 类型安全。

3. 解决方案二:语义路由 (Semantic Routing)

传统的路由是基于关键字的(if "退货" in query)。但如果用户说“我不想要这个东西了”,关键字匹配就会失效。

语义路由利用 Embedding(向量) 技术。我们把每一类问题的“典型描述”算成向量。当新问题进来时,看它和哪个向量最接近,就去哪。

实战:构建一个智能分诊台

我们将定义两条路线:

  1. Math Route: 擅长回答数学物理问题。
  2. General Route: 擅长回答日常闲聊。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
from langchain_openai import OpenAIEmbeddings
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain.utils.math import cosine_similarity

# 1. 定义两条路线的 Prompt
math_template = """你是一个物理学家。请用严谨的公式和科学术语回答。
问题: {query}"""

general_template = """你是一个幽默的脱口秀演员。请用搞笑的方式回答。
问题: {query}"""

# 2. 定义路由的"路标" (Prompt Embeddings)
# 我们需要给出每条路线的"典型问题样例"
route_samples = {
"math": ["什么是牛顿第二定律?", "计算圆的面积", "积分怎么算", "光速是多少"],
"general": ["你好", "给我讲个笑话", "今天天气不错", "你是谁"]
}

embeddings = OpenAIEmbeddings()

# 预先计算路标的向量 (实际生产中这一步应该缓存起来)
print("正在初始化路由向量...")
math_vectors = embeddings.embed_documents(route_samples["math"])
general_vectors = embeddings.embed_documents(route_samples["general"])

# 3. 定义路由逻辑函数
def router(info):
query = info["query"]
# 计算查询语句的向量
query_vector = embeddings.embed_query(query)

# 计算相似度
# 这里的逻辑是:拿 query 和 math 组的所有样例比,取最高分;再和 general 组比。
math_score = max(cosine_similarity([query_vector], math_vectors)[0])
general_score = max(cosine_similarity([query_vector], general_vectors)[0])

print(f"--- 路由打分: Math={math_score:.4f}, General={general_score:.4f} ---")

if math_score > general_score:
return ChatPromptTemplate.from_template(math_template)
else:
return ChatPromptTemplate.from_template(general_template)

# 4. 构建动态链
# RunnableLambda 允许我们在链的中间动态插入函数
chain = (
{"query": RunnablePassthrough()}
| RunnableLambda(router) # 这里决定了使用哪个 Prompt
| ChatOpenAI()
| StrOutputParser()
)

# 5. 测试
print("\n--- 测试 1: 数学问题 ---")
print(chain.invoke("这就好比那个 E=mc平方 是什么意思?"))

print("\n--- 测试 2: 闲聊问题 ---")
print(chain.invoke("嘿,最近过得怎么样?"))

深度解析

当你问“E=mc平方是什么意思”时,虽然这句话里没有“物理”二字,但它在向量空间里与“什么是牛顿第二定律”等问题的距离,远远近于“你好”。 Semantic Routing 让你的应用能够理解“弦外之音”,这是传统 if-else 无法做到的。

4. 进阶展望:未来的 Agent 会是什么样?

至此,我们的 LangChain 实战系列博客就告一段落了。但技术的演进从来不会停止。当你掌握了结构化输出和语义路由后,还有哪些更前沿的方向值得去探索?

1. 本地化与隐私 (Local Agents)

随着 Llama 3、Mistral 等开源模型的强大,越来越多的企业开始尝试在本地部署 Agent。结合 Ollama + LangChain,你可以构建完全离线、隐私安全的 Agent,且无需支付 API 费用。这对于处理敏感数据的金融、医疗场景至关重要。

2. 多模态交互 (Multimodality)

现在的 Agent 大多还停留在“文字对话”阶段。但在 GPT-4o 和 Gemini 1.5 Pro 的推动下,未来的 Agent 将具备“视觉”。 想象一下:用户不再是打字描述“网页报错了”,而是直接截个图发给 Agent。Agent 通过 多模态 RAG 识别图片中的报错代码,并自动给出修复建议。

3. 提示词工程的自动化 (DSPy)

我们还在手动写 Prompt("你是一个物理学家...")。斯坦福大学提出的 DSPy 理念正在改变这一点:它主张通过编程的方式,让模型根据数据自动优化 Prompt,而不是靠人去“猜”哪个 Prompt 更好。虽然 LangChain 也有类似尝试,但这绝对是未来的一大趋势。

结束语

构建 LLM 应用就像搭建乐高积木,LangChain 和 LangGraph 提供了最丰富的零件库。希望这 8 篇文章能成为你手中的图纸,帮你搭建出属于自己的精彩作品。感谢你的阅读!