字符串里有对象怎么办——json 和 ast 搞不定时,4 招焊死 Python 反序列化

本文基于 Python 3.12 编写。所有代码均经过验证,可直接复制运行。

你是不是也卡在这一步

做 RAG 项目时,从向量库或 API 拿回来的数据经常是这样的——

  • 明明是个 list,但套着引号变成了字符串 '["qwen-turbo", "qwen-plus", "deepseek"]',直接遍历拿到的是一个个字符而不是元素?

  • 打印出来看着像 Python 对象 Document(metadata={'page': 2}, page_content='...'),但 type() 一看是 str,点号取属性直接报 AttributeError

  • 搜到 json.loads() 能转字符串,但碰到单引号直接抛 json.decoder.JSONDecodeError

  • 又搜到 ast.literal_eval() 能转单引号,但碰到自定义类名 Document(...) 直接抛 ValueError

如果中了任何一条,问题在于你没分清"什么类型的字符串能用什么工具转"。Python 里字符串反序列化有三层境界,一层比一层猛,但也一层比一层需要谨慎。

核心概念:三种字符串,三种解法

字符串内容的复杂度阶梯

第 1 层:标准 JSON 字符串
  '["qwen-turbo", "qwen-plus"]'
  解法:json.loads()
  特点:双引号、纯基础类型

第 2 层:Python 字面量字符串
  "['qwen-turbo', 'qwen-plus']"
  解法:ast.literal_eval()
  特点:单引号、含 tuple/dict 等 Python 类型

第 3 层:含自定义对象的字符串
  "[Document(metadata={'page': 2}, page_content='...')]"
  解法:正则提取 / eval() / 自定义解析
  特点:含类名调用、无法直接反序列化

打个比方:json.loads() 是安检门,只让标准格式过;ast.literal_eval() 是边检站,Python 原生类型都能过但不放外部类进来;正则提取是徒手拆包,什么都拦不住你但你得自己保证安全。

三种解法对比

| 对比维度 | json.loads() | ast.literal_eval() | 正则提取 |

| — | — | — | — |

| 支持引号 | 仅双引号 " | 单引号 ' 和双引号 " | 不关心引号 |

| 支持 list/dict | 是 | 是 | 需自己解析 |

| 支持 tuple | 否(JSON 无 tuple) | 是 | 需自己解析 |

| 支持自定义类 | 否 | 否 | 需自己解析 |

| 安全性 | 最高 | 高(只评估字面量) | 取决于你的正则 |

| 报错信息 | 明确 | 明确 | 不报错但可能取错值 |

| 适合场景 | API 返回的标准 JSON | Python 对象的字符串形式 | 复杂自定义对象字符串 |

eval() 也能解决第 3 层问题,但它会执行任意代码,生产环境禁止使用。后面会详细讲为什么。

第一题:JSON 字符串转 list

问题描述

raw_str = '["qwen-turbo", "qwen-plus", "deepseek"]'

# 期望结果
# ["qwen-turbo", "qwen-plus", "deepseek"]
# 类型是 list,不是 str

解法:json.loads()

import json

raw_str = '["qwen-turbo", "qwen-plus", "deepseek"]'

# 方法 1:json.loads()(推荐)
result = json.loads(raw_str)
print(result)        # ['qwen-turbo', 'qwen-plus', 'deepseek']
print(type(result))  # <class 'list'>
print(result[0])     # qwen-turbo

json.loads() 把 JSON 格式的字符串反序列化为 Python 对象。字符串里是双引号包裹的数组,反序列化后就是 list,元素是 str。

常见坑:单引号直接炸

import json

# 如果字符串里是单引号
raw_str_single = "['qwen-turbo', 'qwen-plus', 'deepseek']"

result = json.loads(raw_str_single)
# JSONDecodeError: Expecting value: line 1 column 2 (char 1)

JSON 标准要求键和字符串值必须用双引号,单引号在 JSON 里不合法。碰到单引号字符串,json.loads() 直接报错。

解法升级:单引号用 ast.literal_eval()

import ast

raw_str_single = "['qwen-turbo', 'qwen-plus', 'deepseek']"

# ast.literal_eval() 能处理单引号
result = ast.literal_eval(raw_str_single)
print(result)        # ['qwen-turbo', 'qwen-plus', 'deepseek']
print(type(result))  # <class 'list'>

ast.literal_eval() 是 Python 标准库 ast 模块提供的安全评估函数,只解析 Python 字面量(字符串、数字、列表、字典、元组、布尔值、None),不执行函数调用和表达式,安全性远高于 eval()

记住一个判断原则:双引号用 json.loads(),单引号用 ast.literal_eval()。不确定引号类型时,先用 json.loads() 试,报错了再换 ast.literal_eval()

第二题:从 Document 对象字符串中提取 page

问题描述

你拿到的字符串长这样——里面是多个 Document(...) 对象的字符串表示,你需要提取每个 Document 的 page 属性值,拼成一个列表。

raw_str = "[Document(metadata={'pk': 12, 'page': 2}, page_content='2. 费用报销...'), Document(metadata={'pk': 27, 'page': 2}, page_content='2. 费用报销...'), Document(metadata={'pk': 14, 'page': 3}, page_content='3. 最后警告...')]"

期望结果:[2, 2, 3]

为什么 json 和 ast 都搞不定

import json
import ast

raw_str = "[Document(metadata={'pk': 12, 'page': 2}, page_content='...')]"

# json.loads() 报错:不是合法 JSON
json.loads(raw_str)
# JSONDecodeError

# ast.literal_eval() 也报错:Document 不是字面量
ast.literal_eval(raw_str)
# ValueError: malformed node or string

Document 是自定义类名,不是 Python 字面量。ast.literal_eval() 只认 list、dict、tuple、str、int、float、bool、None 这些内置类型,碰到类名调用直接拒绝。

解法一:正则提取(推荐,最安全)

思路:不试图把整个字符串转成对象,而是直接用正则匹配 'page': 数字 这个模式。

import re

raw_str = "[Document(metadata={'pk': 12, 'page': 2}, page_content='2. 费用报销...'), Document(metadata={'pk': 27, 'page': 2}, page_content='2. 费用报销...'), Document(metadata={'pk': 14, 'page': 3}, page_content='3. 最后警告...')]"

# 正则匹配 'page': 后面的数字
pattern = r"'page':\s*(\d+)"

pages = [int(m) for m in re.findall(pattern, raw_str)]
print(pages)  # [2, 2, 3]

关键点拆解:

  • r"'page':\s*(\d+)" 匹配 'page': 后面跟的数字
  • \s* 允许冒号后有空格(比如 'page': 2'page':2
  • (\d+) 捕获组提取数字部分
  • re.findall() 返回所有匹配的捕获组内容,结果是 ['2', '2', '3']
  • 最后用列表推导式把字符串转成 int

正则提取的好处是不执行任何代码,零安全风险。缺点是你要确保正则写对了——如果 metadata 里还有别的字段包含 'page' 字样,可能会误匹配。缩小匹配范围可以加上下文:r"metadata=\{[^}]*'page':\s*(\d+)",限定只在 metadata 字典内匹配。

解法二:正则提取 page_content(进阶)

如果你想提取的不只是 page,还有 page_content 的内容做拼接,可以用更精细的正则:

import re

raw_str = "[Document(metadata={'pk': 12, 'page': 2}, page_content='2. 费用报销...'), Document(metadata={'pk': 27, 'page': 2}, page_content='2. 费用报销...'), Document(metadata={'pk': 14, 'page': 3}, page_content='3. 最后警告...')]"

# 提取每个 Document 的 page 和 page_content
pattern = r"Document\(metadata=\{[^}]*'page':\s*(\d+)[^}]*\},\s*page_content='([^']*)'\)"

matches = re.findall(pattern, raw_str)
for page, content in matches:
    print(f"page={page}, content={content[:30]}...")

# 输出:
# page=2, content=2. 费用报销......
# page=2, content=2. 费用报销......
# page=3, content=3. 最后警告......

这个正则的局限是 page_content='([^']*)' 假设内容中不含单引号。如果 page_content 里有单引号(比如英文缩写 don't),正则会在第一个单引号处截断。实际项目中 page_content 往往包含换行符和复杂文本,更稳健的做法见解法三。

解法三:eval() + 预设类(能跑但生产环境慎用)

如果你的环境完全可控(比如本地脚本、离线处理),可以定义一个空的 Document 类,然后用 eval() 把字符串转成真正的对象列表:

# 先定义 Document 类(只要有 __init__ 接受参数就行)
class Document:
    def __init__(self, metadata, page_content):
        self.metadata = metadata
        self.page_content = page_content

    def __repr__(self):
        return f"Document(metadata={self.metadata}, page_content={self.page_content!r})"


raw_str = "[Document(metadata={'pk': 12, 'page': 2}, page_content='2. 费用报销...'), Document(metadata={'pk': 27, 'page': 2}, page_content='2. 费用报销...'), Document(metadata={'pk': 14, 'page': 3}, page_content='3. 最后警告...')]"

# eval() 直接执行字符串为 Python 代码
docs = eval(raw_str)

# 现在可以像操作对象一样取属性了
pages = [doc.metadata['page'] for doc in docs]
print(pages)  # [2, 2, 3]

# 也可以取 page_content 做拼接
contents = [doc.page_content for doc in docs]
full_text = "\n".join(contents)
print(full_text[:50])

eval() 能工作是因为它把字符串当成 Python 代码直接执行——Document(...) 被当作函数调用,返回真正的 Document 对象。但前提是你的作用域里有 Document 这个类定义。

为什么生产环境禁止 eval()eval() 会执行字符串中的任意代码。如果 raw_str 来自外部输入(API 返回、用户提交、数据库读取),攻击者可以注入恶意代码:eval("__import__('os').system('rm -rf /')")。正则提取不执行代码,所以零风险。只有在数据来源完全可信(比如你自己 str() 生成的)时才可以用 eval()

解法四:自定义解析器(生产环境推荐)

正则提取够快但不灵活,eval() 够灵活但不安全。如果你需要完整对象操作(不只是提取几个字段),又不能保证数据完全可信,可以写一个轻量解析器——用正则把每个 Document(...) 块拆出来,再用 ast.literal_eval() 解析 metadata={...} 部分(dict 是标准字面量,ast 能处理),避开 eval() 的安全风险:

import re
import ast

raw_str = "[Document(metadata={'pk': 12, 'page': 2}, page_content='2. 费用报销...'), Document(metadata={'pk': 27, 'page': 2}, page_content='2. 费用报销...'), Document(metadata={'pk': 14, 'page': 3}, page_content='3. 最后警告...')]"

# 第 1 步:用正则把每个 Document(...) 的内容拆出来
# 捕获组 1 只拿 {...}(不含 metadata= 前缀),捕获组 2 拿 page_content 内容
doc_pattern = r"Document\(metadata=(\{.*?\}),\s*page_content='(.*?)'\)"
matches = re.findall(doc_pattern, raw_str)

# 第 2 步:metadata 部分用 ast.literal_eval 安全解析为 dict
# page_content 部分已经是字符串,直接取用
results = []
for meta_str, content in matches:
    meta_str_clean = meta_str.replace("\\n", "\n")
    metadata = ast.literal_eval(meta_str_clean)  # 安全:dict 是字面量
    results.append({"metadata": metadata, "page_content": content})

# 取 page 属性
pages = [r["metadata"]["page"] for r in results]
print(pages)  # [2, 2, 3]

# 拼接 page_content
full_text = "\n".join(r["page_content"] for r in results)
print(full_text[:50])

思路拆解:

  • 正则 r"Document\(metadata=(\{.*?\}),\s*page_content='(.*?)'\)" 把每个 Document 的两个参数拆出来
  • metadata= 写在捕获组外面,捕获组 1 只拿 {...} 字典部分(非贪婪,匹配到第一个 }),这样 ast.literal_eval() 拿到的就是合法 dict 字面量
  • page_content='(.*?)' 匹配 page_content 内容(非贪婪,匹配到下一个单引号)
  • metadata 部分是标准 Python dict 字面量,ast.literal_eval() 安全解析
  • page_content 部分本身在引号内,正则提取后就是字符串

这个方案的安全性在于:ast.literal_eval() 只解析 dict 字面量,不执行任何函数调用。即使 raw_str 中被注入恶意代码,ast.literal_eval() 也只会报 ValueError 而不会执行。局限是正则仍假设 page_content 中不含单引号——如果内容复杂到正则无法可靠拆分,就该回到源头改序列化方式了。

四种解法横向对比

| 维度 | 正则提取 | eval() + 预设类 | 自定义解析器 |

| — | — | — | — |

| 安全性 | 最高(不执行代码) | 最低(执行任意代码) | 高(可控逻辑) |

| 实现难度 | 低(一行正则) | 低(定义空类 + eval) | 中高(需写解析逻辑) |

| 取值灵活度 | 仅限正则能匹配的 | 完整对象操作 | 完整对象操作 |

| 健壮性 | 中等(正则可能误匹配) | 高(Python 解释器保证) | 高(自己控制边界) |

| 适合场景 | 快速提取少量字段 | 本地脚本、数据完全可信 | 生产环境、需要完整对象 |

| 能处理单引号嵌套 | 不能 | 能 | 能 |

实际 RAG 项目中,如果向量库返回的检索结果被 str() 转成了字符串,最推荐的做法是:在源头修复——不要把对象 str() 成字符串,而是用 picklejson 或框架自带的序列化方法。字符串里嵌对象本来就是反模式,正则提取只是补救措施。

常见问题与排坑

| 问题 | 原因 | 解决方案 |

| — | — | — |

| json.loads()JSONDecodeError | 字符串用了单引号 | 换 ast.literal_eval() |

| ast.literal_eval()ValueError | 字符串含类名调用(如 Document(...)) | 用正则提取或 eval() + 预设类 |

| 正则提取 page_content 被截断 | 内容中含单引号,正则在第一个单引号处停止 | 用非贪婪匹配配合更宽的字符集,或直接用 eval() + 预设类 |

| eval()NameError: name 'Document' is not defined | 作用域中没有定义 Document 类 | 先定义类再调 eval() |

| 正则匹配到多余的结果 | metadata 中有其他字段名包含 'page' | 加上下文限定:r"metadata=\{[^}]*'page':\s*(\d+)" |

| json.loads() 解析含 NaN/Infinity 的字符串 | json.loads() 默认接受这两个值(返回 nan/inf,非标准 JSON 扩展),需严格拒绝时用 parse_constant 参数 | ast.literal_eval() 不认这两个值,会抛 ValueError |

| 字符串里有多层嵌套引号 | page_content 本身含引号或换行 | 正则很难处理,推荐 eval() + 预设类或自定义解析 |

| 换行符 \n 在字符串中被转义 | str() 转换时换行符变成字面 \n | eval() 会自动还原;正则提取后需手动 replace('\\n', '\n') |

什么情况不该用这些方法

场景一:数据源可以修改序列化方式

如果你的数据是从自己的代码里 str() 出来的,别反序列化——改成 json.dumps() / pickle.dumps() 序列化,json.loads() / pickle.loads() 反序列化。从源头用标准序列化格式,省掉所有反序列化的麻烦。注意 pickle.loads() 同样能执行任意代码,对不可信数据同样危险,优先用 json

场景二:数据来自不可信来源

如果字符串来自 API 返回、用户输入或数据库中不受控的数据,绝对不能用 eval()。正则提取是唯一安全的选择。

场景三:超长字符串(MB 级以上)

正则在大字符串上可能回溯爆炸导致性能问题。超长字符串建议分块处理或用专门的解析器。

速查表

| 项目 | 内容 |

| — | — |

| 第 1 层:标准 JSON 字符串 | json.loads(),要求双引号 |

| 第 2 层:Python 字面量字符串 | ast.literal_eval(),支持单引号和 tuple |

| 第 3 层:含自定义对象的字符串 | 正则提取(安全)、eval() + 预设类(慎用),或自定义解析器(生产推荐) |

| 引号判断原则 | 双引号 → json,单引号 → ast |

| 正则提取 page | r"'page':\s*(\d+)" |

| 正则提取(限定 metadata) | r"metadata=\{[^}]*'page':\s*(\d+)" |

| eval 前提 | 作用域中必须已定义对应类 |

| eval 安全原则 | 仅限数据完全可信时使用 |

| 生产环境推荐 | 正则提取 / 自定义解析器(安全);json 和 ast 同样安全但只适用前两层 |

| 根本解法 | 从源头用标准序列化格式,不要 str() 对象 |

核心知识点回顾

1. 三层字符串对应三种工具

标准 JSON(双引号)用 json.loads(),Python 字面量(单引号)用 ast.literal_eval(),含自定义类的字符串用正则提取、eval() + 预设类,或自定义解析器(生产推荐)。判断顺序从简单到复杂:先试 json,报错换 ast,再报错上正则。

2. json 和 ast 的边界

json.loads() 只认双引号、基础类型(str/int/float/bool/None/list/dict)。ast.literal_eval() 多支持了单引号和 tuple,但不支持类名调用。碰到 Document(...) 这种,两个都搞不定。

3. 正则提取是最安全的补救措施

不执行任何代码,零注入风险。缺点是只能提取简单字段,碰到嵌套引号和复杂文本容易断裂。加上下文限定(如 metadata=\{[^}]*...)可以减少误匹配。

4. eval() 能用但要用对场景

eval() 是万能的但也是最危险的——它执行字符串中的任意代码。只有数据来源完全可控(自己 str() 生成的、本地离线脚本)时才可以用。生产环境碰到不可信数据,正则是唯一选择。

5. 根本解法是源头修复

字符串里嵌对象本来就是反模式。如果你的代码控制了序列化环节,改成 json.dumps() / pickle.dumps(),反序列化时用对应方法,一劳永逸。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐