{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# 07 输出解析器\n", "\n", "## 学习目标\n", "1. 理解输出解析器(Output Parsers)在 LangChain 链中的作用\n", "2. 掌握常见解析器的用法:StrOutputParser、JsonOutputParser、PydanticOutputParser、CommaSeparatedListOutputParser\n", "3. 学会使用 `手动 try-except + LLM 修复` 处理模型输出格式异常\n", "4. 了解如何编写自定义输出解析器\n", "5. 理解「Prompt 中说明格式 + 解析器约束」配合的重要性" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 1. 为什么需要输出解析器\n", "\n", "大模型返回的通常是**自由文本**,但很多时候我们需要:\n", "\n", "- 提取结构化的 JSON 数据\n", "- 把文本转换成 Python 列表、字典或对象\n", "- 验证输出是否符合预期的数据格式\n", "- 过滤掉模型多余的解释,只保留关键信息\n", "\n", "**输出解析器**就是链中负责把模型输出转换为结构化数据的组件。\n", "\n", "在 LCEL 中,链的常见结构是:\n", "\n", "```\n", "chain = prompt | llm | output_parser\n", "```\n", "\n", "其中 `output_parser` 接收 `AIMessage` 对象,输出我们需要的 Python 数据结构。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 2. StrOutputParser:最简单的字符串解析器\n", "\n", "`StrOutputParser` 会把模型返回的 `AIMessage` 对象直接转换成纯字符串。\n", "\n", "它适合的场景:\n", "- 只需要文本回答\n", "- 不想每次手动写 `.content`\n", "- 作为链的最后一步统一输出类型" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from langchain_openai import ChatOpenAI\n", "from langchain_core.prompts import ChatPromptTemplate\n", "from langchain_core.output_parsers import StrOutputParser\n", "from dotenv import load_dotenv\n", "\n", "load_dotenv()\n", "\n", "\n", "# 创建模型\n", "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", "\n", "# 创建 Prompt 模板\n", "prompt = ChatPromptTemplate.from_messages([\n", " ('system', '你是一个简洁的助手。'),\n", " ('user', '用一句话介绍{topic}')\n", "])\n", "\n", "# 构建链:prompt -> llm -> 字符串解析器\n", "chain = prompt | llm | StrOutputParser()\n", "\n", "# 调用链,直接得到字符串\n", "result = chain.invoke({'topic': '机器学习'})\n", "\n", "print('返回类型:', type(result))\n", "print('返回内容:', result)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 代码解释\n", "\n", "| 代码 | 作用 |\n", "| --- | --- |\n", "| `prompt \\| llm \\| StrOutputParser()` | 用管道符串联三个组件 |\n", "| `StrOutputParser()` | 自动提取 `.content` 并返回字符串 |\n", "| `type(result)` | 验证返回结果是否为 `str` |\n", "\n", "不加 `StrOutputParser()` 时,`chain.invoke()` 返回的是 `AIMessage` 对象;加了之后返回的是 `str`。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 3. JsonOutputParser:解析 JSON 输出\n", "\n", "`JsonOutputParser` 会自动把模型输出的 JSON 字符串解析成 Python 字典。\n", "\n", "使用要点:\n", "1. 在 Prompt 中明确要求模型输出 JSON\n", "2. JSON 中的大括号 `{` 在 Prompt 模板中需要写成 `{{` 进行转义\n", "3. 模型输出必须能被 `json.loads()` 解析" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from langchain_openai import ChatOpenAI\n", "from langchain_core.prompts import ChatPromptTemplate\n", "from langchain_core.output_parsers import JsonOutputParser\n", "\n", "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", "\n", "# 注意:模板中的 {{ 和 }} 会被 LangChain 渲染成单个 { 和 }\n", "system_template = '''你是一个信息提取助手。请只输出 JSON 格式,不要包含任何解释。\n", "\n", "输出格式如下:\n", "{{\\n\n", " \"names\": [...],\\n\n", " \"locations\": [...],\\n\n", " \"time\": \"\"\\n\n", "}}'''\n", "\n", "prompt = ChatPromptTemplate.from_messages([\n", " ('system', system_template),\n", " ('user', '文本:{text}')\n", "])\n", "\n", "chain = prompt | llm | JsonOutputParser()\n", "\n", "result = chain.invoke({\n", " 'text': '2024年5月1日,李明和王芳一起去了北京故宫参观。'\n", "})\n", "\n", "print('返回类型:', type(result))\n", "print('返回内容:', result)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 代码解释\n", "\n", "- `system_template` 中的 `{{` 和 `}}` 是**LangChain 模板转义语法**,表示最终 Prompt 中显示为单个 `{` 和 `}`\n", "- `JsonOutputParser()` 会调用 `json.loads()` 解析模型输出\n", "- 如果模型输出不是合法 JSON,会抛出异常\n", "\n", "返回类型是 `dict`,可以直接像 `result['names']` 这样访问字段。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 4. PydanticOutputParser:解析为 Pydantic 对象\n", "\n", "如果你需要更严格的类型校验和结构化数据,可以使用 `PydanticOutputParser`。\n", "\n", "Pydantic 是 Python 中非常流行的数据验证库。通过定义数据模型,可以:\n", "- 明确每个字段的类型\n", "- 自动验证数据格式\n", "- 把模型输出转换成可操作的 Python 对象" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from langchain_openai import ChatOpenAI\n", "from langchain_core.prompts import ChatPromptTemplate\n", "from langchain_core.output_parsers import PydanticOutputParser\n", "from pydantic import BaseModel, Field\n", "\n", "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", "\n", "# 第一步:定义 Pydantic 数据模型\n", "class PersonInfo(BaseModel):\n", " name: str = Field(description='人物姓名')\n", " age: int = Field(description='人物年龄')\n", " city: str = Field(description='所在城市')\n", " hobbies: list[str] = Field(description='兴趣爱好列表')\n", "\n", "# 第二步:创建解析器\n", "parser = PydanticOutputParser(pydantic_object=PersonInfo)\n", "\n", "# 第三步:在 Prompt 中嵌入格式说明\n", "prompt = ChatPromptTemplate.from_messages([\n", " ('system', '''你是一个信息提取助手。请从文本中提取人物信息,按指定格式输出。\\n\n", "{format_instructions}'''),\n", " ('user', '文本:{text}')\n", "])\n", "\n", "# format_instructions 会自动生成 JSON Schema 说明\n", "prompt_with_format = prompt.partial(format_instructions=parser.get_format_instructions())\n", "\n", "# 构建链\n", "chain = prompt_with_format | llm | parser\n", "\n", "result = chain.invoke({\n", " 'text': '张三今年25岁,住在杭州,喜欢打篮球和编程。'\n", "})\n", "\n", "print('返回类型:', type(result))\n", "print('返回对象:', result)\n", "print('姓名:', result.name)\n", "print('年龄:', result.age)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 代码解释\n", "\n", "| 步骤 | 代码 | 说明 |\n", "| --- | --- | --- |\n", "| 1 | `class PersonInfo(BaseModel)` | 定义数据结构,每个字段都有类型和描述 |\n", "| 2 | `PydanticOutputParser(pydantic_object=PersonInfo)` | 创建解析器,指定要解析的模型 |\n", "| 3 | `parser.get_format_instructions()` | 自动生成格式说明,告诉模型如何输出 |\n", "| 4 | `prompt.partial(format_instructions=...)` | 把格式说明预先填充到 Prompt 中 |\n", "| 5 | `chain.invoke(...)` | 返回的是 `PersonInfo` 对象 |\n", "\n", "Pydantic 会自动校验类型,比如 `age` 必须是整数,否则报错。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 5. CommaSeparatedListOutputParser:解析逗号分隔列表\n", "\n", "当你需要模型返回一个列表时,可以使用 `CommaSeparatedListOutputParser`。\n", "\n", "它适合的场景:\n", "- 提取关键词\n", "- 生成待办事项\n", "- 输出多个选项" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from langchain_openai import ChatOpenAI\n", "from langchain_core.prompts import ChatPromptTemplate\n", "from langchain_core.output_parsers import CommaSeparatedListOutputParser\n", "\n", "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", "\n", "# 创建解析器\n", "parser = CommaSeparatedListOutputParser()\n", "\n", "# 获取格式说明\n", "format_instructions = parser.get_format_instructions()\n", "\n", "prompt = ChatPromptTemplate.from_messages([\n", " ('system', '你是一个关键词提取助手。{format_instructions}'),\n", " ('user', '请从以下文本中提取3-5个关键词:{text}')\n", "])\n", "\n", "# 预填充格式说明\n", "prompt_with_format = prompt.partial(format_instructions=format_instructions)\n", "\n", "chain = prompt_with_format | llm | parser\n", "\n", "result = chain.invoke({\n", " 'text': '人工智能、机器学习和深度学习正在改变各个行业的运作方式。'\n", "})\n", "\n", "print('返回类型:', type(result))\n", "print('关键词列表:', result)\n", "print('第一个关键词:', result[0])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 代码解释\n", "\n", "- `CommaSeparatedListOutputParser()` 会自动生成格式说明:「用逗号分隔各项,不要带编号和多余解释」\n", "- `parser.get_format_instructions()` 返回一段英文说明文本\n", "- 返回结果是 Python 列表 `list[str]`\n", "- 可以直接用索引访问,比如 `result[0]`" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 6. 处理格式异常:手动 try-except + LLM 修复\n", "\n", "大模型有时不会严格按 JSON 格式输出,可能包含多余文字、缺失引号或注释。\n", "\n", "`手动 try-except + LLM 修复` 可以包装另一个解析器,当第一次解析失败时,自动调用 LLM 修复输出格式。" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from langchain_openai import ChatOpenAI\n", "from langchain_core.prompts import ChatPromptTemplate\n", "from langchain_core.output_parsers import JsonOutputParser\n", "import json\n", "\n", "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", "\n", "# 定义一个 JSON 解析器\n", "parser = JsonOutputParser()\n", "\n", "# 提示模型输出 JSON,但不强制约束格式\n", "prompt = ChatPromptTemplate.from_messages([\n", " ('system', '请把用户信息转换成 JSON 格式输出。'),\n", " ('user', '内容:{text}')\n", "])\n", "\n", "# 先获取模型的原始文本输出\n", "raw_chain = prompt | llm\n", "raw_output = raw_chain.invoke({'text': '姓名:李四,年龄:30,城市:上海'})\n", "\n", "print('===== 原始模型输出 =====')\n", "print(raw_output.content)\n", "\n", "# 尝试解析 JSON\n", "try:\n", " result = parser.invoke(raw_output)\n", " print('\\n===== 直接解析成功 =====')\n", " print(result)\n", "except Exception as e:\n", " print('\\n===== 解析失败,尝试用 LLM 修复 =====')\n", " print('错误信息:', e)\n", "\n", " # 构造修复 Prompt,让模型把错误输出修正为合法 JSON\n", " fix_prompt = ChatPromptTemplate.from_messages([\n", " ('system', '你是一个 JSON 修复专家。请把用户提供的内容修正为合法 JSON,只输出 JSON 字符串,不要任何解释。'),\n", " ('user', '原始内容:\\n{raw_output}\\n\\n错误信息:{error}')\n", " ])\n", "\n", " fix_chain = fix_prompt | llm\n", " fixed_output = fix_chain.invoke({\n", " 'raw_output': raw_output.content,\n", " 'error': str(e)\n", " })\n", "\n", " # 手动用 json.loads 解析修复后的内容\n", " result = json.loads(fixed_output.content)\n", " print('\\n===== 修复后的结果 =====')\n", " print(result)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 代码解释\n", "\n", "- `手动 try-except + LLM 修复.from_llm(parser=parser, llm=llm)` 用同一个 LLM 修复格式错误\n", "- 修复逻辑:第一次解析失败 → 把原始输出和错误信息传给 LLM → 请求模型修正为合法格式 → 再次解析\n", "- 适合用于对稳定性要求较高的场景\n", "- 注意:修复不一定 100% 成功,极端情况下仍会报错" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 7. 自定义输出解析器\n", "\n", "如果内置解析器不能满足需求,你可以继承 `BaseOutputParser` 自己实现。\n", "\n", "下面是一个简单的自定义解析器:把模型输出按指定分隔符切分成列表。" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from langchain_openai import ChatOpenAI\n", "from langchain_core.prompts import ChatPromptTemplate\n", "from langchain_core.output_parsers import BaseOutputParser\n", "\n", "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", "\n", "# 自定义解析器:按换行符切分,并去除空行和序号\n", "class NumberedListParser(BaseOutputParser[list[str]]):\n", " \"\"\"把模型输出的编号列表解析成字符串列表。\"\"\"\n", "\n", " def parse(self, text: str) -> list[str]:\n", " # 按行切分\n", " lines = text.strip().split('\\n')\n", " # 去除空行,并去掉每行开头的 1. 2. 3. 等序号\n", " items = []\n", " for line in lines:\n", " line = line.strip()\n", " if not line:\n", " continue\n", " # 去掉行首的数字和点,例如 \"1. \"\n", " if '. ' in line[:4]:\n", " line = line.split('. ', 1)[1]\n", " items.append(line)\n", " return items\n", "\n", "prompt = ChatPromptTemplate.from_messages([\n", " ('system', '你是一个清单生成助手。请输出编号列表,每行一个项目。'),\n", " ('user', '请列出学习{topic}的5个步骤')\n", "])\n", "\n", "chain = prompt | llm | NumberedListParser()\n", "\n", "result = chain.invoke({'topic': 'Python 编程'})\n", "\n", "print('返回类型:', type(result))\n", "print('返回内容:', result)\n", "print('步骤数量:', len(result))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 代码解释\n", "\n", "- 继承 `BaseOutputParser[T]`,泛型 `T` 表示返回类型\n", "- 必须实现 `parse(self, text: str) -> T` 方法\n", "- `text` 是模型输出的字符串(已经过 `StrOutputParser` 提取 `.content`)\n", "- 可以在 `parse` 中做任何字符串处理:切分、清洗、正则匹配、校验等\n", "\n", "自定义解析器的优势是完全可控,适合处理模型输出不规范的情况。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 8. 完整示例:结构化数据提取\n", "\n", "下面是一个综合示例:从一段商品评价中同时提取评分、优点、缺点和关键词。" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from langchain_openai import ChatOpenAI\n", "from langchain_core.prompts import ChatPromptTemplate\n", "from langchain_core.output_parsers import PydanticOutputParser\n", "from pydantic import BaseModel, Field\n", "\n", "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", "\n", "class Review(BaseModel):\n", " sentiment: str = Field(description='整体情感,正面/负面/中性')\n", " pros: list[str] = Field(description='优点列表')\n", " cons: list[str] = Field(description='缺点列表')\n", " keywords: list[str] = Field(description='关键词列表')\n", "\n", "parser = PydanticOutputParser(pydantic_object=Review)\n", "\n", "prompt = ChatPromptTemplate.from_messages([\n", " ('system', '''你是一个商品评价分析助手。请从评价中提取结构化信息。\\n\n", "{format_instructions}\\n\n", "只输出 JSON,不要其他内容。'''),\n", " ('user', '商品评价:{review}')\n", "])\n", "\n", "prompt_with_format = prompt.partial(format_instructions=parser.get_format_instructions())\n", "\n", "chain = prompt_with_format | llm | parser\n", "\n", "review_text = '''这款手机外观很漂亮,拍照效果也不错。\n", "但是电池续航一般,充电速度有点慢。\n", "总体来说性价比还可以。'''\n", "\n", "result = chain.invoke({'review': review_text})\n", "\n", "print('情感:', result.sentiment)\n", "print('优点:', result.pros)\n", "print('缺点:', result.cons)\n", "print('关键词:', result.keywords)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 9. 常见解析器对比\n", "\n", "| 解析器 | 返回类型 | 适用场景 |\n", "| --- | --- | --- |\n", "| `StrOutputParser` | `str` | 只需要文本内容 |\n", "| `JsonOutputParser` | `dict` | 需要 JSON 字典 |\n", "| `PydanticOutputParser` | Pydantic 对象 | 需要类型校验和结构化对象 |\n", "| `CommaSeparatedListOutputParser` | `list[str]` | 逗号分隔的列表 |\n", "| `手动 try-except + LLM 修复` | 依赖被包装的解析器 | 自动修复格式错误 |\n", "| 自定义 `BaseOutputParser` | 任意类型 | 特殊格式或复杂后处理 |" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 10. 本节课练习\n", "\n", "1. 使用 `StrOutputParser()` 构建一个链,让模型介绍一门编程语言,验证返回类型是否为 `str`\n", "2. 使用 `JsonOutputParser()` 从一段地址文本中提取省、市、区、街道,输出为 JSON 字典\n", "3. 定义一个 Pydantic 模型 `BookInfo`(书名、作者、出版年份、类型),用 `PydanticOutputParser` 从文本中提取图书信息\n", "4. 使用 `CommaSeparatedListOutputParser()` 让模型列出学习 LangChain 的 5 个关键知识点\n", "5. 尝试让模型故意输出不规范的 JSON,然后用 `手动 try-except + LLM 修复` 包装 `JsonOutputParser` 观察是否能修复" ] } ], "metadata": { "kernelspec": { "display_name": ".venv", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.11.3" } }, "nbformat": 4, "nbformat_minor": 4 }