《Python AI 应用开发入门》第 3.6 节。
读写并验证 JSON 配置与聊天历史,实现配置和代码分离。
本节目标
学完本节后,你应当能够:
- 1. 区分 JSON 文本与 Python 对象。
- 2. 使用
dumps()、loads()、dump() 和 load()。
1. JSON 是什么
JSON 是跨语言交换结构化数据的文本格式:
{ "model": "demo-model", "temperature": 0.7, "streaming":false, "system_prompt":null}
它看起来像 Python 字典,但不是 Python 代码。
| |
|---|
| dict |
| list |
| str |
| int |
true | True |
null | None |
JSON 不支持元组、集合、Path 和任意 Python 对象。写入前必须转换为 JSON 可表示的数据。
2. dumps():对象变字符串
import jsonconfig = { "model": "demo-model", "temperature": 0.7, "streaming": False,}text = json.dumps( config, ensure_ascii=False, indent=2,)print(text)
- •
ensure_ascii=False:中文直接显示,不转成 \u...。 - •
indent=2:格式化为便于阅读的多行文本。
结果是 str,还没有写入文件。
3. loads():字符串变对象
import jsontext = '{"model": "demo-model", "temperature": 0.7}'config = json.loads(text)print(type(config))print(config["model"])
JSON 无效时触发 json.JSONDecodeError:
# json.loads('{"model": "demo-model",}')
标准 JSON 不允许最后一个字段后保留多余逗号,也不允许普通注释。
4. dump() 与 load()
它们直接操作已经打开的文件对象:
import jsonfrom pathlib import Pathpath = Path("config.json")config = {"model": "demo-model", "max_history": 50}with path.open(mode="w", encoding="utf-8") as file: json.dump(config, file, ensure_ascii=False, indent=2)
读取:
with path.open(mode="r", encoding="utf-8") as file: loaded_config = json.load(file)
对比:
- •
dumps / loads 中的 s 可以记作 string。
本课程也常用 Path.read_text() 配合 loads(),因为异常边界更容易看清。
5. JSON 解析成功不等于配置有效
下面是合法 JSON:
{ "model": "", "max_history": -10}
但业务配置无效。因此读取配置分两步:
不能只要 json.loads() 成功就直接使用。
6. 配置文件设计
chat_app/data/config.json:
{ "model": "demo-model", "style": "简洁", "max_history": 50}
配置适合保存:
不适合保存并提交:
敏感配置应从环境变量或专门密钥服务读取。
7. 读取并验证配置
JSON 数据来自文件,类型注解不能自动验证它是否符合要求,因此需要在程序运行时检查。isinstance() 是 Python 内置的类型检查函数,基本语法是:
isinstance(对象, 类型)
对象属于指定类型时返回 True,否则返回 False:
print(isinstance("demo-model", str)) # Trueprint(isinstance(50, int)) # Trueprint(isinstance([], dict)) # False
第二个参数也可以传入由多个类型组成的元组:
print(isinstance(3.5, (int, float))) # True
not isinstance(data, dict) 可以读作“data 不是字典”。相比 type(data) == dict,通常更推荐 isinstance(),因为它也能识别指定类型的子类。
下面使用它检查 JSON 根节点和各个配置项的类型:
import jsonfrom pathlib import Pathfrom typing import AnyDEFAULT_CONFIG = { "model": "demo-model", "style": "简洁", "max_history": 50,}def load_config(path: Path) -> dict[str, Any]: if not path.exists(): return DEFAULT_CONFIG.copy() try: text = path.read_text(encoding="utf-8") data = json.loads(text) except PermissionError as error: raise ValueError(f"没有权限读取配置:{path}") from error except UnicodeDecodeError as error: raise ValueError(f"配置文件不是有效 UTF-8:{path}") from error except json.JSONDecodeError as error: raise ValueError( f"配置 JSON 无效,第 {error.lineno} 行第 {error.colno} 列" ) from error if not isinstance(data, dict): raise ValueError("配置根节点必须是 JSON object") model = data.get("model") style = data.get("style", DEFAULT_CONFIG["style"]) max_history = data.get( "max_history", DEFAULT_CONFIG["max_history"], ) if not isinstance(model, str) or not model.strip(): raise ValueError("model 必须是非空字符串") if not isinstance(style, str) or not style.strip(): raise ValueError("style 必须是非空字符串") if ( not isinstance(max_history, int) or isinstance(max_history, bool) or max_history <= 0 ): raise ValueError("max_history 必须是大于 0 的整数") return { "model": model.strip(), "style": style.strip(), "max_history": max_history, }
注意:bool 是 int 的子类,所以整数校验时额外排除布尔值。
8. 聊天历史 JSON
[ { "role": "user", "content": "什么是 JSON?" }, { "role": "assistant", "content": "JSON 是结构化文本格式。" }]
它自然对应:
list[dict[str, str]]
9. 验证单条消息
from typing import Anydef validate_message(value: Any) -> dict[str, str]: if not isinstance(value, dict): raise ValueError("每条消息必须是 JSON object") role = value.get("role") content = value.get("content") if role not in ("system", "user", "assistant"): raise ValueError("消息 role 无效") if not isinstance(content, str) or not content.strip(): raise ValueError("消息 content 必须是非空字符串") return { "role": role, "content": content, }
解析外部数据后不能依赖类型注解,必须运行时检查。
10. 加载聊天历史
import jsonfrom pathlib import Pathdef load_history(path: Path) -> list[dict[str, str]]: if not path.exists(): return [] try: data = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as error: raise ValueError( f"聊天历史 JSON 无效,第 {error.lineno} 行" ) from error if not isinstance(data, list): raise ValueError("聊天历史根节点必须是 JSON array") messages = [] for index, value in enumerate(data, start=1): try: message = validate_message(value) except ValueError as error: raise ValueError(f"第 {index} 条消息无效:{error}") from error messages.append(message) return messages
错误信息包含消息序号,定位更容易。
11. 保存聊天历史
import jsonfrom pathlib import Pathdef save_history( path: Path, messages: list[dict[str, str]],) -> None: validated_messages = [] for message in messages: validated_messages.append(validate_message(message)) path.parent.mkdir(parents=True, exist_ok=True) text = json.dumps( validated_messages, ensure_ascii=False, indent=2, ) path.write_text(text + "\n", encoding="utf-8")
写入前再次验证,避免把无效内存数据持久化。
12. 使用临时文件再替换
直接覆盖时如果程序中途终止,原文件可能只写了一部分。可以先写临时文件:
def save_history( path: Path, messages: list[dict[str, str]],) -> None: validated_messages = [] for message in messages: validated_message = validate_message(message) validated_messages.append(validated_message) text = json.dumps( validated_messages, ensure_ascii=False, indent=2, ) path.parent.mkdir(parents=True, exist_ok=True) temporary_path = path.with_suffix(path.suffix + ".tmp") temporary_path.write_text(text + "\n", encoding="utf-8") temporary_path.replace(path)
替换通常比直接逐步覆盖更安全,但它不是数据库事务,也没有解决多个进程同时写入的问题。
13. 限制历史长度
def trim_history( messages: list[dict[str, str]], max_history: int,) -> list[dict[str, str]]: if max_history <= 0: raise ValueError("max_history 必须大于 0") return messages[-max_history:]
保存前:
messages = trim_history(messages, config["max_history"])save_history(history_path, messages)
返回新列表,不暗中修改调用者传入的原列表。
14. 入口层处理配置错误
def main() -> None: try: config = load_config(CONFIG_PATH) messages = load_history(HISTORY_PATH) except ValueError as error: print(f"启动失败:{error}") return print(f"模型:{config['model']}") print(f"恢复消息:{len(messages)} 条")
配置损坏时不要静默使用默认值,否则用户可能不知道自己的设置完全没有生效。文件不存在可以明确设计为首次启动并使用默认值。
15. JSON 常见问题
把 Python 字面量写进 JSON
错误:
{ "streaming": True, "system_prompt": None}
JSON 必须使用 true 和 null。
保存集合
# json.dumps({"tags": {"Python", "AI"}})
集合不可直接序列化,应先转换为排序后的列表:
data = {"tags": sorted({"Python", "AI"})}
把 JSON 当安全配置
JSON 只是文本格式,不会自动加密或隐藏内容。
动手练习
练习 1:配置加载器
完成 load_config(path),测试:
- •
max_history 为布尔值、负数和有效整数。
练习 2:历史持久化
实现 load_history() 与 save_history()。保存三条中文消息,关闭程序后重新加载并比较内容。
练习 3:损坏数据定位
手工把第二条消息的 content 改成空字符串,确认错误信息能指出“第 2 条消息”。
随堂小测
- 1. JSON 与 Python 字典是什么关系?
- 2.
dumps() 与 dump() 有什么区别? - 3.
ensure_ascii=False 有什么作用? - 5.
JSONDecodeError 提供哪些定位信息? - 8. 真实 API Key 为什么不能写入 JSON 并提交?
参考答案
- 1. JSON object 解析后通常得到 Python 字典,但 JSON 是独立文本格式,不是 Python 代码。
- 2.
dumps() 返回字符串,dump() 写入已打开的文件对象。 - 4. 合法 JSON 仍可能有错误结构、类型、缺失字段或越界值。
- 6. Python 中
bool 是 int 的子类,但业务上通常不能把真假当数量。 - 8. JSON 是明文,仓库、日志和其他人都可能读取。
- 9. 不一定。不存在可以代表首次启动;损坏通常应明确报错,避免静默忽略用户数据。
本节完成检查
- • 我能在 JSON 与 Python 对象之间转换。
- • 我使用 UTF-8 保存了可读中文 JSON。