1. 为什么这几个知识点会同时出现在一条技术栈里
先说个实际场景。
上个月我接了一个小需求:手里有一批从不同渠道采集的商品信息,全部是 JSON 格式,字段还不统一,有的带 tags 数组,有的没有;大类目和小类目混着写;同一个商品在多个文件里重复出现。处理完之后还要把这些结构化数据交给大模型,让它帮忙做一轮类目归并和描述润色,最后再落回 JSON。整个链路走完,我发现自己绕不开的就这五样东西:Python 模块与包、集合、类与对象、JSON 处理、阿里云百炼 AI 接入。
这五个知识点单独拆开,每一个都是 Python 基础教程里的常见章节,但它们很少被放在一条真实的工作流里讲。网上的资料通常是这样:讲模块就是 import 语法,讲集合就是 set() 去重,讲 JSON 就是 json.loads() 和 json.dumps(),讲百炼就是复制一段官方示例跑通。看起来都懂,真到项目里要把它们串起来的时候,环节之间的衔接才最容易出问题。
这篇文章就是把我实际做这个需求的过程、代码、踩坑记录完整贴出来。适合已经学完 Python 基础语法、知道 list 和 dict 是什么,但还没完整写过一个小工程的读者。当然,如果你正准备把大模型能力接进自己的 Python 项目,后面关于百炼接入的篇幅可以直接当参考。
我不打算把每个知识点写成百科全书,那没有意义。我按照一条真实业务的处理顺序来讲:先规划项目结构,再用集合做数据去重和归并,接着定义类来承载结构化数据,然后用 JSON 完成读写,最后接入阿里云百炼的 AI 接口做语义处理。每一步之间是逻辑递进的关系,而不是零散的知识点堆砌。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 模块与包:项目结构的正确打开方式
2.1 一个真实小项目的目录是怎么长出来的
很多人写 Python 脚本是从单文件开始的,这没什么不对,我最初也是这样。但当脚本超过 300 行,或者同一个工具函数被两个脚本共用,单文件的维护成本就开始飙升。我之前就干过一件蠢事:把处理 JSON 的工具函数在三个脚本里各复制了一份,后来需求变了要改日期格式的解析逻辑,我改了第一个文件忘了第二个,线上直接出了一批脏数据。
所以这次我一开始就把项目拆成了包结构。所谓包,就是一个带 __init__.py 文件的目录。Python 把目录当成包,靠的就是这个文件——在 Python 3.3 之后即使没有它也可以隐式命名空间包,但为了兼容性和明确性,我还是习惯保留它,哪怕里面什么都不写。
我的目录长这样:
code复制product_pipeline/
├── __init__.py
├── config.py
├── models.py
├── json_utils.py
├── ai_client.py
└── main.py
这个结构对应的职责划分是:
config.py:存放 API Key、文件路径、模型名称这些环境相关配置。models.py:定义商品数据类,也就是后面要讲的类与对象。json_utils.py:JSON 读、写、清洗、校验的工具函数。ai_client.py:封装阿里云百炼的调用逻辑。main.py:入口脚本,编排整个处理流程。
这种按职责划分的好处是:哪天你要把 ai_client.py 里的调用逻辑换成别的服务商,你只需要动这一个文件,其他模块完全不受影响。
2.2 init.py 和相对导入的坑
包和普通目录最大的区别在于导入方式。你可以用绝对导入:
python复制from product_pipeline.models import Product
也可以用相对导入:
python复制from .models import Product
这两个方式我都用过,各有各的坑。
绝对导入的问题是:如果你直接 python main.py 运行,Python 会把 main.py 所在目录加入 sys.path,那 import product_pipeline 的时候它去上一级目录找包,大概率找不到。解决办法是代码里手动把项目根目录加进路径:
python复制import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
这段代码我几乎每个项目都会写一次,属于那种"不解释原理也能用,但理解了原理更踏实"的东西:sys.path 就是 Python 找模块的搜索路径列表,你把自己项目根目录插进去,之后任何绝对导入都能正常解析。
相对导入的坑更隐蔽。如果你在包里某个模块直接写 from .models import Product,那这个文件必须以包的形式被导入,不能直接 python models.py 运行。我曾经为了测试单独跑了包里的某个模块,结果直接报 ImportError: attempted relative import with no known parent package。排查了半天才反应过来,不是代码写错了,是运行方式不对。
2.3 把工具函数放对位置,比命名优雅更重要
我在 json_utils.py 里放了两个核心函数,都特别短:
python复制def load_json(file_path: str):
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
def dump_json(file_path: str, data, indent=2):
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=indent)
ensure_ascii=False 这个参数非常关键,我第一次处理中文 JSON 时不知道加它,写出来的文件全是 \uXXXX 转义序列,虽然解析没问题,但人看着完全没法排查数据。加上它之后,保存的 JSON 文件里就是正常的中文。
工具函数放对位置的价值在于:你会在多个入口脚本里重复调用它们。比如我后来新增了一个 incremental_update.py,里面也要读 JSON,直接 from product_pipeline.json_utils import load_json 就行,不用再写一遍文件打开关闭逻辑。
这里分享一个我总结的模块划分原则:如果一段代码你在两个地方都写过,而且没有本质区别,那就值得提取成公共函数。但不要过度拆分,一个 20 行的工具函数没有必要拆成三个模块,否则找起来更累。
3. 集合:去重、交集与标签聚合的实战用法
3.1 为什么用集合而不是列表
处理这批商品数据的时候,我遇到的头一个问题就是重复。同一个商品 ID 在多个来源文件里出现,有的来源字段全,有的来源字段缺,不能简单丢弃,得把字段合并。合并之前先得知道一共有多少个唯一商品 ID。
这其实就是集合最典型的应用场景。Python 的 set 是基于哈希表实现的,底层的查找、插入、删除操作的平均时间复杂度是 O(1),数据量一大,和列表的差距就非常明显。我实测过,10 万个 ID 用集合去重,耗时大概几十毫秒;如果用列表加 if id not in list 的方式去重,那复杂度直接变成 O(n²),可能要跑几十秒。这不是性能强迫症的问题,是量级差异。
所以我的第一步很简单:
python复制all_ids = set()
for file in source_files:
data = load_json(file)
for item in data:
all_ids.add(item["id"])
print(f"去重前文件内共有 {sum(len(load_json(f)) for f in source_files)} 条,去重后唯一 ID {len(all_ids)} 个")
3.2 标签归并:交集差集帮你找到数据分布
拿到了唯一 ID 集合之后,另一件事是分析不同来源文件之间的重叠情况。这一步直接决定后续的数据合并策略。
比如我有三个来源:A 文件里有 2000 个商品,B 文件有 1500 个,C 文件有 500 个。我需要知道这三个来源之间重叠了多少。
python复制ids_a = {item["id"] for item in load_json("source_a.json")}
ids_b = {item["id"] for item in load_json("source_b.json")}
ids_c = {item["id"] for item in load_json("source_c.json")}
common_ab = ids_a & ids_b # 交集
only_a = ids_a - ids_b - ids_c # 差集
all_union = ids_a | ids_b | ids_c # 并集
这里有个细节值得注意:{item["id"] for item in load_json(...)} 是集合推导式,它和列表推导式唯一的区别是外层括号是花括号,生成的就是 set。这一行代码同时完成了遍历、提取、去重三件事,比先建列表再去 set() 转一次更直接。
交集和差集的结果,在我这个项目里直接决定了合并策略:同时出现在 A 和 B 中的 ID,以字段更全的那个来源为准;只出现在 A 中的,保留 A 的数据,但要去 AI 那边补一轮字段校验;只出现在 C 中的,基本就是新增商品,需要完整走一遍清洗流程。
3.3 集合操作的三个隐性问题
集合用起来很爽,但有几个隐藏问题我需要提醒一下。
第一,集合元素必须是可哈希的。常见的 str、int、tuple 都没问题,但 list 和 dict 不行,因为它们是可变的。你如果想把一组标签组成的列表塞进集合里去重,必须先转成元组:
python复制tag_sets = set()
for item in data:
tag_sets.add(tuple(item["tags"])) # 而不是 tag_sets.add(item["tags"])
我自己在这个地方栽过跟头,直接 add 一个列表,控制台立刻报 TypeError: unhashable type: 'list'。
第二,两个商品虽然有同一个 ID,但其中一个的标签是 ["手机", "数码"],另一个是 ["数码", "手机"]。转成元组之后这两个是不同元素,因为元组是有序的。如果你要的是"标签内容相同就算相同",需要先把列表排序再转元组:
python复制sorted_tags = tuple(sorted(item["tags"]))
第三,集合本身的遍历顺序是不确定的。在 Python 3.7 之后,字典保持插入顺序,但集合没有这个保证。如果后续要把集合转成列表再写入 JSON,顺序不一致会让对比结果变得很折腾。解决办法是显式排序:
python复制sorted_ids = sorted(all_ids)
这个坑特别隐蔽,因为小数据量下集合顺序看起来还挺"正常",但一上十万条,顺序就开始乱了。
4. 类与对象:用 dataclass 管理结构化数据
4.1 一直用 dict 行不行
说实话,处理 JSON 数据用 dict 完全可以,我最早就是这么干的。但项目一复杂,dict 的弱点就很明显:字段名靠字符串硬编码,写错一个大小写不会立刻报错,等运行到某个角落才发现 KeyError;数据的状态和操作方法散落在各个函数里,没有聚合在同一个地方;最难受的是重构时,你改了一个字段名,所有用到它的地方都要跟着改,编译器帮不了你,只能靠人肉搜索。
类与对象不是用来炫技的,它是用来解决这些实际问题的。Python 里最地道的方式是使用 dataclasses 模块,它帮你省掉了写 __init__、__repr__、__eq__ 这些模板代码的麻烦。
4.2 一个商品类的设计过程
这个需求里我定义了 Product 类,长这样:
python复制from dataclasses import dataclass, field
@dataclass
class Product:
id: str
title: str
category: str
price: float
tags: list[str] = field(default_factory=list)
source: str = "unknown"
有几个细节要展开讲。
第一,field(default_factory=list) 这个写法。如果你写成 tags: list[str] = [],Python 会把这个空列表作为默认值绑定到类定义上,所有没有显式传 tags 的实例会共享同一个列表对象。一个实例往里加了数据,另一个实例也会看到。这就是经典的"可变默认参数陷阱"。default_factory=list 的意思是每次创建实例时调用 list() 生成一个新列表,彻底避开共享问题。
第二,dataclass 默认生成的 __repr__ 方法对于调试特别友好。比如你 print(product),输出是 Product(id='P001', title='iPhone 15 Pro', category='手机', price=7999.0, tags=['手机', '5G'], source='A'),一眼就能看清所有字段。要是用普通 class,你写 __repr__ 一般就图省事返回个 f-string,字段一多照样乱。
第三,如果你要控制字段的顺序和默认值,注意 dataclass 构造时默认值必须排在无默认值字段后面。所以我的写法是 id、title、category、price 在前,tags 和 source 带默认值的放后面。
4.3 加上方法之后,类才真正有灵魂
光有属性的类和 dict 没有本质区别,类的核心价值在于把操作数据的方法也放进来。
比如我需要判断一个商品是否属于"高价值"类目,直接在类里定义方法:
python复制@dataclass
class Product:
id: str
title: str
category: str
price: float
tags: list[str] = field(default_factory=list)
source: str = "unknown"
def is_high_value(self, threshold: float = 5000) -> bool:
return self.price >= threshold
再比如我需要把一个商品实例转成字典,方便后续 json.dumps,定义:
python复制 def to_dict(self) -> dict:
return {
"id": self.id,
"title": self.title,
"category": self.category,
"price": self.price,
"tags": self.tags,
"source": self.source,
}
有了这两个方法,处理循环就干净多了:
python复制products = []
for raw in raw_data:
p = Product(**raw) # 把 dict 解包成关键字参数
if p.is_high_value():
products.append(p.to_dict())
Product(**raw) 这一行尤其有用,它的前提是 raw 这个字典的键和 Product 的字段名完全一致。如果来源文件字段名不一致(比如一个是 name 一个是 title),就需要在解包前做一层映射。这也是我后来决定用类的一个理由:映射逻辑写在一个地方,比散落在各处更容易维护。
4.4 枚举和常量的配合
类目字段一开始我用的是自由字符串,结果发现同一个类目有"手机"和"智能手机"两种写法,数据分布乱得很。后来我引入了自定义枚举,把合法类目固定下来:
python复制from enum import Enum
class Category(Enum):
PHONE = "手机"
COMPUTER = "电脑"
AUDIO = "音频"
ACCESSORY = "配件"
然后 Product 的 category 字段类型改为 Category。这样做有两个实际好处:非法类目在创建实例时就会被拦截,不会带着脏数据跑到下游;后续做聚合统计时可以用 Category.PHONE.value 得到统一的中文字符串,避免别名问题。
注意一点:Category.PHONE.value 输出的是 "手机",而 Category.PHONE 本身是一个枚举成员。如果直接 json.dumps 枚举成员会报错,需要手动用 .value 转换,我的 to_dict 方法里已经做了处理。
5. JSON:语言之间的通用数据协议
5.1 JSON 在 Python 里的核心操作
JSON 本身是一种数据交换格式,它和 Python 的 dict 长得像,但不完全等价。Python 的 json 模块只做了类型映射:object 对 dict,array 对 list,string 对 str,number 对 int 或 float,boolean 对 bool,null 对 None。如果 JSON 里有 Python 没有对应类型的数据(比如日期类型),json.loads 只当成普通字符串,需要你手动解析。
基本操作四件套:
python复制import json
# 字符串转 Python 对象
data = json.loads('{"name": "张三", "age": 30}')
# Python 对象转字符串
json_str = json.dumps(data, ensure_ascii=False)
# 文件读取
with open("data.json", "r", encoding="utf-8") as f:
data = json.load(f)
# 文件写入
with open("output.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
5.2 这个项目里我重新认识了 JSON 的读取方式
这个项目的难点不在于读写,而在于数据的结构不一致。我遇到的情况是:同一个字段"价格",在 A 来源里是字符串 "7999.00",在 B 来源里是数字 7999.0,在 C 来源里直接没有这个字段。如果直接拿原始数据塞进 Product(**raw),要么类型不匹配(字符串赋给 float 字段会报错),要么缺字段直接 KeyError。
所以我在 json_utils.py 里加了一个统一的清洗函数:
python复制def clean_product_raw(raw: dict) -> dict:
cleaned = {}
cleaned["id"] = str(raw.get("id", "")).strip()
cleaned["title"] = str(raw.get("title", raw.get("name", ""))).strip()
cleaned["category"] = raw.get("category", raw.get("cat", "未分类"))
price = raw.get("price", raw.get("sale_price", 0))
if isinstance(price, str):
price = float(price.replace(",", "").replace("元", ""))
cleaned["price"] = float(price)
tags = raw.get("tags", [])
if isinstance(tags, str):
tags = [t.strip() for t in tags.split("|") if t.strip()]
cleaned["tags"] = tags
cleaned["source"] = raw.get("source", "unknown")
return cleaned
这个函数有几个地方是典型的现实处理逻辑:raw.get("title", raw.get("name", "")) 是字段名映射,同一个语义的字段在不同来源里叫法不同;isinstance(price, str) 分支处理价格被写成字符串的情况;replace(",", "") 处理千分位逗号;replace("元", "") 去掉货币单位。
JSON 本身是一门非常宽松的格式,越宽松越需要你自己做防御性编程。不要相信来源文件里的字段一定存在、类型一定正确,用 .get() 加默认值,用 isinstance() 做类型分支,这是在真实数据场景里摸爬滚打出来的经验。
5.3 大文件读取和流式处理
如果 JSON 文件很大(比如上 GB 的数据),一次性 json.load 会把整个文件读进内存,可能直接内存溢出。这时候需要流式读取,Python 的 ijson 库可以做到边读边解析,但标准库方案是逐行判断。
一个折中方案是用 json.JSONDecoder 配合 raw_decode 方法:
python复制import json
def load_json_stream(file_path: str):
decoder = json.JSONDecoder()
with open(file_path, "r", encoding="utf-8") as f:
buffer = f.read()
index = 0
while index < len(buffer):
while index < len(buffer) and buffer[index].isspace():
index += 1
if index >= len(buffer):
break
obj, end = decoder.raw_decode(buffer, index)
yield obj
index = end
它处理的是一个文件里包含多个 JSON 对象(以空白字符分隔)的情况。每个对象独立解码,内存占用可控。我在处理小文件时依然用 json.load,因为流式方案代码复杂得多,不值得为几 MB 的文件付出这个成本。
5.4 JSON Schema 校验:上线前的一道安全网
处理完清洗后的数据,写文件之前,我加了一步校验。标准做法是用 jsonschema 库:
python复制from jsonschema import validate, ValidationError
schema = {
"type": "object",
"required": ["id", "title", "category", "price", "tags"],
"properties": {
"id": {"type": "string"},
"title": {"type": "string"},
"category": {"type": "string"},
"price": {"type": "number"},
"tags": {"type": "array", "items": {"type": "string"}}
}
}
validated = []
for item in data:
try:
validate(instance=item, schema=schema)
validated.append(item)
except ValidationError as e:
# 记录错误,但不中断整个流程
logger.warning(f"校验失败: {item.get('id')} - {e.message}")
这一步的意义在于:把数据层面的错误在写文件之前拦截下来,而不是等到 AI 接口调用时报错才开始排查。数据管道的每一个环节都应该有交付物,JSON 文件的交付物就是一个 Schema 校验通过的文件。
6. 阿里云百炼 AI 接入:把大模型能力变成项目的一部分
6.1 百炼是什么,为什么选它
百炼是阿里云提供的模型服务平台,提供对通义千问系列模型(qwen-plus、qwen-turbo 等)的 API 访问。简单理解就是:你不必自己部署大模型,注册账号拿 API Key,通过 HTTP 接口就能调用千问模型的能力。对开发者来说,这是把大模型能力集成进自己项目的最快路径。
选择百炼的原因有几个:模型能力成熟稳定,中文理解质量高,这个项目处理的商品数据全是中文标签,用中文优化的模型更有优势;SDK 是 Python 原生支持,安装配置非常直接;价格相对友好,qwen-turbo 针对大规模批处理场景成本很低。
6.2 安装和权限配置的标准化流程
安装 SDK 很简单:
bash复制pip install dashscope
配置 API Key 时我非常推荐一个做法:不要硬编码在代码里,而是放在环境变量中。一是防止不小心把 Key 提交到 Git 仓库,二是换环境时不用改代码。
Linux / macOS 环境:
bash复制export DASHSCOPE_API_KEY="sk-xxxxxxxxxxxxxxxx"
Windows PowerShell 环境:
powershell复制$env:DASHSCOPE_API_KEY="sk-xxxxxxxxxxxxxxxx"
然后在 Python 里读取:
python复制import os
import dashscope
dashscope.api_key = os.environ.get("DASHSCOPE_API_KEY")
if not dashscope.api_key:
raise RuntimeError("请在环境变量中设置 DASHSCOPE_API_KEY")
这一步我单独放在 ai_client.py 的模块顶部。任何依赖 AI 功能的脚本,import 这个模块时就会先检查 Key 是否存在,跑不起来时第一时间给出明确提示,而不是调用接口报认证错误后让人猜。
6.3 我的第一版调用代码和它踩的坑
第一版代码非常简单:
python复制import os
import dashscope
from dashscope import Generation
dashscope.api_key = os.environ.get("DASHSCOPE_API_KEY")
response = Generation.call(
model="qwen-plus",
messages=[
{"role": "system", "content": "你是一个商品信息整理助手。"},
{"role": "user", "content": "请把这条商品标题整理得更规范:iPhone15Pro 256GB 原色钛金属"}
]
)
print(response)
跑通之后我用它处理了测试数据。输出结果正常,但整个调用是同步阻塞的,一批 500 条商品数据大概要跑 10 多分钟。我需要优化两个方向:第一是尽量用流式输出,提前拿到内容,而不是等服务端生成完整个回复再返回;第二是加入批处理并发。
流式输出的写法:
python复制responses = Generation.call(
model="qwen-plus",
messages=[...],
stream=True
)
for response in responses:
if response.status_code == 200:
print(response.output.text, end="")
流式输出的 responses 是一个可迭代对象,每拿一块就处理一块。注意这里 response.status_code 的判断放在循环内,因为每个分片都是独立响应对象,不是最后统一返回一个结果。
6.4 让 AI 返回结构化 JSON 的关键技巧
我的业务需求不是让 AI 做普通对话,而是要它返回一个合法的 JSON 对象,方便后续程序直接解析。这里有一个非常重要的技巧:在 prompt 里明确要求输出格式,并且在得到内容之后做一次容错解析。
第一版 prompt 我写得随意,结果 AI 返回的内容里既有解释性文字又有 JSON 块,直接 json.loads 报错。后来我改成这样的结构:
python复制prompt = f"""
请根据以下商品信息生成规范化的 JSON:
商品ID: {product.id}
原标题: {product.title}
原始类目: {product.category}
标签: {product.tags}
要求:
1. 只输出符合以下结构的 JSON,不要输出任何其他解释文字:
{{"id": "", "title": "", "category": "", "suggested_tags": []}}
2. title 需要保持简洁清晰,适合电商展示。
3. category 需要归并到统一类目体系。
"""
这里有几个技巧值得展开说。
第一,在 prompt 里给出 JSON 的模板结构,包括字段名和类型,让模型照着填空。模型对"照着模板输出"的遵循度远高于"给我一段 JSON"这种模糊指令。
第二,用"不要输出任何其他解释文字"做硬性约束。即使这样,模型偶尔还是会输出 Markdown 代码块包着的内容,比如:
code复制```json
{"id": "P001", ...}
```
直接 json.loads 还是会失败。所以我在解析时做了容错处理:
python复制import json
import re
def extract_and_parse_json(text: str):
# 先尝试直接解析
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 去掉 Markdown 代码块标记
text = re.sub(r'```json\s*|\s*```', '', text).strip()
# 尝试解析整个修复后的文本
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 最后尝试从 '{' 开始截取到最后一个 '}'
start = text.find('{')
end = text.rfind('}')
if start != -1 and end != -1:
try:
return json.loads(text[start:end + 1])
except json.JSONDecodeError:
pass
raise ValueError(f"无法从 AI 响应中解析 JSON: {text[:200]}")
这个方法按照"直接解析 → 去 Markdown 标记 → 截取花括号对"三级降级策略做容错。实际跑下来成功率能从 80% 提升到 99% 以上,剩余那不到 1% 是模型返回了空内容或完全跑题,这种情况丢弃重试就好。
6.5 错误处理和重试机制的实战写法
调用外部 API 和调用本地函数完全不同,网络抖动、服务端限流、参数错误都有可能发生。我加了统一的重试机制:
python复制import time
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
class AIResponseError(Exception):
pass
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type((AIResponseError, ConnectionError)),
reraise=True
)
def call_ai_with_retry(prompt: str) -> str:
response = Generation.call(
model="qwen-plus",
messages=[
{"role": "system", "content": "你是一个商品信息整理助手。"},
{"role": "user", "content": prompt}
],
result_format="message"
)
if response.status_code != 200:
raise AIResponseError(f"API 返回错误: {response.code} - {response.message}")
try:
content = response.output.choices[0].message.content
return content
except (AttributeError, IndexError) as e:
raise AIResponseError(f"解析响应结构失败: {e}")
tenacity 库的重试配置看几个地方:stop_after_attempt(3) 是最多重试 3 次;wait_exponential 是退避策略,等待时间按 2 秒、4 秒、8 秒递增,避免服务端还没恢复就疯狂重试;retry_if_exception_type 指定只有特定异常才重试,如果是代码逻辑错误(比如 prompt 格式写错),重试多少次都没用,不应该浪费请求。重试机制的思维是"对外部服务保持敬畏,对内部逻辑保持严格"。
6.6 用并发把 10 分钟的活压到 2 分钟
批处理场景下,同步调用太慢了。我改用 ThreadPoolExecutor 做并发:
python复制from concurrent.futures import ThreadPoolExecutor, as_completed
def process_one(product):
prompt = build_prompt(product)
content = call_ai_with_retry(prompt)
return product.id, extract_and_parse_json(content)
results = {}
with ThreadPoolExecutor(max_workers=8) as executor:
future_map = {executor.submit(process_one, p): p.id for p in products}
for future in as_completed(future_map):
pid = future_map[future]
try:
rid, parsed = future.result()
results[rid] = parsed
except Exception as e:
logging.error(f"处理商品 {pid} 失败: {e}")
这里线程数我选了 8,没有盲目开更高,因为 API 服务端本身有并发限制,开太多反而触发限流导致重试更频繁。实际测试下来,8 个并发在百炼的免费额度内稳定,速度提升大约是同步的 4 倍。如果配额更高,可以根据服务端文档调整这个值。
as_completed 的好处是:先完成的任务先处理结果,不会因为个别商品请求慢而阻塞整个队列。每个任务的结果通过 future.result() 获取,异常也被捕获记录,不会让一个失败的商品拖死整个批处理。
7. 整套串联:一个迷你实战项目的完整代码
把前面讲的所有知识点串起来,就是这个项目的主流程代码。我把它贴在这里,配合注释说明每个环节对应哪个知识点。
python复制import os
import sys
import json
import logging
from pathlib import Path
# 将项目根目录加入模块搜索路径(模块与包)
PROJECT_ROOT = Path(__file__).resolve().parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
# 使用包内模块(模块与包、类与对象)
from json_utils import load_json, dump_json, clean_product_raw
from models import Product, Category
from ai_client import call_ai_with_retry, extract_and_parse_json
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
def deduplicate_and_merge(source_files: list[str]):
"""数据去重与合并:集合的核心应用场景"""
merged = {}
for source_file in source_files:
raw_items = load_json(source_file)
logger.info(f"处理来源文件 {source_file},共 {len(raw_items)} 条")
for raw in raw_items:
cleaned = clean_product_raw(raw)
pid = cleaned["id"]
if pid not in merged:
merged[pid] = cleaned
else:
# 字段覆盖策略:已存在的记录,如果价格或类目为空则补全
for field in ("title", "category", "price", "tags", "source"):
if not merged[pid].get(field) and cleaned.get(field):
merged[pid][field] = cleaned[field]
# 顺序稳定,方便对比(集合排序)
return [merged[pid] for pid in sorted(merged.keys())]
def build_prompt(product: Product) -> str:
return f"""
请根据以下商品信息生成规范化的 JSON:
商品ID: {product.id}
原标题: {product.title}
原始类目: {product.category}
标签: {product.tags}
要求:
1. 只输出符合以下结构的 JSON,不要输出任何其他解释文字:
{{"id": "", "title": "", "category": "", "suggested_tags": []}}
2. title 需要保持简洁清晰,适合电商展示。
3. category 需要归并到统一类目体系。
"""
def ai_refine(products: list[Product]) -> list[dict]:
"""调用百炼 AI 对商品信息做规范化处理:AI 接入的封装层"""
from concurrent.futures import ThreadPoolExecutor, as_completed
refined = []
def process_one(product: Product):
content = call_ai_with_retry(build_prompt(product))
return product.id, extract_and_parse_json(content)
with ThreadPoolExecutor(max_workers=8) as executor:
future_map = {executor.submit(process_one, p): p.id for p in products}
for future in as_completed(future_map):
pid = future_map[future]
try:
rid, parsed = future.result()
refined.append(parsed)
logger.info(f"AI 处理完成: {rid}")
except Exception as e:
logger.error(f"AI 处理失败 {pid}: {e}")
return refined
def main():
# 第一步:读取多个来源文件,去重合并(集合)
source_files = [
"data/source_a.json",
"data/source_b.json",
"data/source_c.json",
]
merged_data = deduplicate_and_merge(source_files)
logger.info(f"合并去重后共 {len(merged_data)} 条商品")
dump_json("output/merged.json", merged_data)
# 第二步:转换为 Product 对象,按规则筛选(类与对象)
products = [Product(**item) for item in merged_data]
high_value_products = [p for p in products if p.is_high_value(5000)]
logger.info(f"高价值商品 {len(high_value_products)} 条")
# 第三步:调用 AI 规范化(AI 接入)
refined = ai_refine(high_value_products)
# 第四步:写回 JSON(JSON 读写)
dump_json("output/refined.json", refined)
logger.info("处理完成,结果已写入 output/refined.json")
if __name__ == "__main__":
main()
这段主流程代码几乎每一行都能对应到一个独立的 Python 知识点:sys.path.insert 是模块导入问题,merged[pid] 去重聚合是集合思想,Product(**item) 是类与对象的实例化,load_json/dump_json 是 JSON 操作,ai_refine 中的 call_ai_with_retry 和 extract_and_parse_json 是 AI 接入的封装。
把这五个知识点串成一整条流程之后,你会意识到一件事:单独学一个知识点时,你对它的理解是静态的;只有在数据从文件流向内存、再从内存流向外部 API、最后回到文件的过程中,你才会真正理解为什么要做类型校验、为什么要做异常处理、为什么要用集合来去重而不是列表。
8. 真实项目里最容易踩的高频坑清单
这篇内容写到最后,我把整个过程中踩过、以及身边朋友踩过的高频坑汇总成一张清单,按环节分类。你不一定现在就能遇到,但遇到的时候回来看一眼,能省不少排查时间。
8.1 模块与包环节
最经典的就是相对导入报错。ImportError: attempted relative import with no known parent package 出现时,大多数情况不是代码逻辑错了,而是运行方式错了——你直接 python models.py 而不是 python -m product_pipeline.main。我后来养成的习惯是:所有入口脚本统一放在项目根目录,用 python main.py 启动,包内模块一律用绝对导入(以项目根目录为基准),不做相对导入。
还有一个常见问题是两个包同名导致误导入。你项目里有一个 utils.py,Site-Packages 里也有一个 utils,import utils 到底导入哪个取决于 sys.path 的查找顺序。解决办法是包名起得足够具体,不要用 utils、common 这种泛滥的名字。
8.2 集合环节
TypeError: unhashable type: 'list' 是我见过出现频率最高的集合相关报错,解法就是转元组。另外要注意 set 没有下标访问,不能写 all_ids[0],要先 list(all_ids)[0] 或 sorted(all_ids)[0]。这看起来是小事,但新手很容易下意识把集合当列表用。
8.3 类与对象环节
dataclass 可变默认值问题前面已经讲过,还有一个高频坑是:如果你从 JSON 里读到一个字典,直接用 Product(**item),但 JSON 里有个多余的字段是 Product 没有的,会直接抛 TypeError: __init__() got an unexpected keyword argument 'xxx'。解决办法是解包前做一层字段过滤:
python复制valid_fields = set(Product.__dataclass_fields__.keys())
product = Product(**{k: v for k, v in item.items() if k in valid_fields})
Product.__dataclass_fields__ 是 dataclass 自动生成的字段元信息字典,通过它可以动态获取类定义了哪些字段,然后过滤掉多余的键。
8.4 JSON 环节
json.loads 遇到超大整数会解析成 Python 的 int,如果这个数字超过了 JSON 规范本身的安全整数范围(2^53 - 1),前后端交互时精度会丢。解决方法是使用 parse_int 参数:
python复制data = json.loads(json_str, parse_int=lambda x: int(x))
数字保留为字符串:
python复制data = json.loads(json_str, parse_int=str)
在这个项目里,商品 ID 是纯数字字符串,但在传输过程中被某些框架自动转成了 int,回来时精度丢失,ID 对不上。后来我在清洗的时候就统一转成字符串保存,避免此问题。
8.5 百炼 AI 接入环节
调用接口返回 401 认证错误,90% 的原因是环境变量没设置或者 Key 写错了,先检查这两项,不要怀疑代码逻辑。返回 400 时,大多数是 prompt 里的消息格式不对。另外,dashscope 的旧版 SDK 里 Generation.call 的 result_format 参数不设时默认返回纯文本,output.text 可访问;设成 "message" 后返回 OpenAI 风格的 output.choices[0].message.content。两种格式取内容的方式完全不同,我第一次切换时踩了这个坑。
AI 返回的内容偶尔会乱码或截断,尤其当 max_tokens 设置得太小。我处理商品数据的 prompt 通常需要 800 到 1200 tokens,不设置时默认值可能不够,导致返回的半截 JSON 解析失败。设置:
python复制Generation.call(..., max_tokens=2000)
把上限放大之后,截断问题几乎消失。
8.6 一个重要的认知
最后我想说一个观点:这些高频坑不是独立存在的,它们背后有一个共同的规律——真实数据永远比你想象的更脏,真实外部服务永远比你想象的更不稳定,真实项目永远比你想象的更需要防御性编程。你学的每一个知识点,如果只是停留在"能运行",那它只是知识;当你开始思考"这个数据如果是空字段怎么办""这个接口超时怎么办""这个类被其他模块复用时会不会踩共享状态的坑",你才真正把这些知识变成了工程能力。
