《Python AI 应用开发入门》第 4.5 节。
运用 datetime、uuid、collections、logging、os 和 python-dotenv,完善对话核心的时间记录、唯一标识、统计、日志和环境配置。
本节目标
学完本节后,你应当能够:
- 2. 使用带时区的
datetime 记录消息时间。 - 3. 使用 ISO 8601 字符串保存时间,并将其恢复为时间对象。
- 4. 使用
uuid4() 为会话和消息生成唯一标识。 - 7. 使用
defaultdict 按键分组数据。 - 8. 在程序入口统一配置日志,并在业务代码中记录关键事件。
- 9. 将日志同时输出到终端和文件,并能确定日志文件的实际路径。
- 11. 使用
python-dotenv 加载 .env,再通过 os.getenv() 读取和验证配置。 - 12. 将本章的数据模型与对象协作方式整合为一个完整的对话核心。
1. 什么是标准库
安装 Python 时,一批常用模块也会随之安装,它们合称 Python 标准库。标准库不需要再用 pip 安装,但使用前仍然需要先 import:
import loggingimport osfrom pathlib import Pathimport uuidfrom collections import Counterfrom datetime import datetime
第三方包不属于 Python 自带内容,通常由社区或厂商维护。使用前,需要先把它们安装到当前虚拟环境,例如后续课程会用到的 HTTP 客户端和 Web 框架。
“属于标准库”只说明模块随 Python 一同提供,并不意味着它适合所有场景,也不意味着调用时一定不会出错。使用前,仍要弄清它能解决什么问题、有哪些使用限制。
本节将结合对话核心,学习以下几个常用模块:
- •
collections:完成计数、分组和有界队列等操作。
后文还会使用 python-dotenv 加载 .env。它不是标准库,而是项目开发中常用的第三方包,需要单独安装;将它放在本节,是为了把环境配置的实际使用流程讲完整。
2. 正确处理时间与时区
先来看如何获取当前的 UTC 时间:
from datetime import datetime, timezonecreated_at = datetime.now(timezone.utc)print(created_at)
datetime.now() 用来获取当前时间。传入 timezone.utc 后,得到的是 UTC 时间,并且结果中会明确保留时区信息。
如果不传时区,会得到一个不带时区信息的时间,Python 文档称它为“朴素时间”:
naive_time = datetime.now()
这类对象虽然包含年月日和时分秒,却没有说明它表示的是北京时间、UTC,还是其他地区的时间。程序只在本机运行时,问题可能并不明显;一旦需要跨地区部署或与 API 交换数据,同一个时间点就可能被错误理解。
因此,后端程序通常统一保存带时区的 UTC 时间,展示给用户时再转换为当地时间。
3. 使用 ISO 8601 保存时间
JSON 只能保存字符串、数字、列表和字典等基础类型,不能直接保存 datetime 对象。写入 JSON 前,通常先把时间转换为 ISO 8601 格式的字符串:
from datetime import datetime, timezonecreated_at = datetime.now(timezone.utc)created_at_text = created_at.isoformat()print(created_at_text)
输出类似:
2026-08-06T08:30:00.123456+00:00
读取数据后,可以把字符串恢复为 datetime 对象:
restored_time = datetime.fromisoformat(created_at_text)print(restored_time)print(restored_time.tzinfo)
isoformat() 把时间对象转换为字符串,fromisoformat() 则把字符串恢复为时间对象。使用这组方法,比手动拼接和拆分年月日更清楚,也不容易遗漏时区信息。
不过,来自外部的字符串仍可能格式错误或缺少时区。读取不可信数据时,要捕获 ValueError,并检查 tzinfo 是否符合要求。
4. 用 UUID 生成稳定标识
每条消息都应该拥有稳定、独立的标识。列表索引不适合作为长期消息 ID:一旦删除或调整消息顺序,后续消息的索引也会随之改变。
标准库中的 uuid 模块可以生成这样的标识:
from uuid import uuid4message_id = str(uuid4())print(message_id)
输出类似:
6a850f09-65fe-4a9d-9484-99e6c22f3fd8
uuid4() 基于随机数据生成 UUID。多次调用得到相同结果的概率极低,因此很适合为本课程中的会话、消息和请求生成彼此独立的标识。
使用 UUID 时需要注意:
- • 即使 UUID 很难猜,也不能用它代替访问权限检查。
- • 写入 JSON 前通常使用
str() 转为字符串。
5. 用 Counter 统计消息角色
先看使用普通字典计数的写法:
counts = {}for message in messages: role = message.role counts[role] = counts.get(role, 0) + 1
如果只是想统计每个值出现了多少次,可以直接使用 collections.Counter:
from collections import Counterrole_counts = Counter( message.role for message in messages)print(role_counts["user"])print(role_counts["assistant"])
Counter 的用法与字典相近,但访问尚未出现的键时会得到 0,因此不必事先判断键是否存在。
查看最常见项目:
print(role_counts.most_common(2))
Counter 适合处理单纯的计数问题。如果还要为每个角色保存权限、显示名称等复杂信息,就应该改用结构更明确的数据模型。
6. 用 deque 保留最近记录
如果程序只关心“最近三条命令”,普通列表也能实现,但需要手动检查长度并删除旧数据。deque 是双端队列,既能限制最大长度,也能高效地从两端添加或删除元素:
from collections import dequerecent_commands = deque(maxlen=3)recent_commands.append("list")recent_commands.append("add user 你好")recent_commands.append("recent 5")recent_commands.append("exit")print(list(recent_commands))
输出只保留最近三项:
['add user 你好', 'recent 5', 'exit']
maxlen=3 表示最多保留三项。队列达到上限后,如果继续从右侧 append(),左侧最旧的元素会被自动移除。
适合 deque 的场景:
deque 只负责内存中的队列操作,不会自动将数据写入文件。需要长期保存的完整聊天记录,仍然可以使用列表并将其写入 JSON。
7. 用 defaultdict 按角色分组
如果要按角色把消息放进不同列表,使用普通字典时,需要先为第一次出现的角色创建空列表:
grouped_messages = {}for message in messages: if message.role not in grouped_messages: grouped_messages[message.role] = [] grouped_messages[message.role].append(message)
defaultdict 可以预先指定“键不存在时要创建什么”:
from collections import defaultdictgrouped_messages = defaultdict(list)for message in messages: grouped_messages[message.role].append(message)
这里将 list 函数传给 defaultdict。第一次访问某个新角色时,它会调用 list() 创建空列表,然后再执行 append()。
defaultdict 主要用于简化程序内部的处理逻辑。写入 JSON,或传给只接收普通字典的代码之前,可以先进行转换:
serializable_groups = dict(grouped_messages)
8. print() 和日志各自适合什么场景
print() 通常用于直接向用户显示程序结果,也适合在学习和调试时临时观察变量:
日志主要供开发者和运维人员查看,用于了解程序内部的运行情况:
业务模块通常会先创建当前模块专用的日志记录器:
import logginglogger = logging.getLogger(__name__)
这里传入的 __name__ 就是当前模块名。即使多个文件都在记录日志,也能通过日志中的名称判断事件来自哪个模块。
9. 配置并使用日志
9.1 在程序入口配置日志
创建 logger 只是取得一个日志记录器,还需要设置最低记录级别和输出格式。基础配置通常只在程序入口执行一次:
import logginglogging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s",)
level=logging.INFO 表示只处理 INFO 及以上级别的日志,DEBUG 日志会被忽略。format 决定每条日志显示哪些信息;上面的配置会依次显示时间、级别、模块名和消息正文。
如果只使用这段配置,日志默认输出到终端的标准错误流 sys.stderr,不会自动生成日志文件。在普通终端中,它会和程序输出一起显示;在 VS Code 或 PyCharm 中,通常会出现在运行控制台。
9.2 在业务代码中记录日志
入口完成配置后,业务模块只需要根据事件的严重程度调用对应方法:
logger = logging.getLogger(__name__)def save_session(session_id, message_count): logger.info( "正在保存会话 session_id=%s message_count=%s", session_id, message_count, )logger.debug("准备读取会话缓存")logger.warning("会话历史即将达到上限")logger.error("会话保存失败")
日志消息中的 %s 是占位符,后面的参数会依次填入。相比提前使用 f-string 拼接,日志更推荐这种写法:如果当前级别不需要输出这条消息,日志系统可以省去不必要的字符串格式化。
常用级别:
| |
|---|
DEBUG | |
INFO | |
WARNING | |
ERROR | |
CRITICAL | |
级别表达的是事件的严重程度。用户把命令输错通常不是系统故障,而保存文件失败也不应只藏在 DEBUG 中。
9.3 同时输出到终端和文件
需要长期保留日志时,可以显式添加 FileHandler。下面的配置会把同一条日志同时输出到终端和 logs/chat.log:
import loggingfrom pathlib import Pathproject_dir = Path(__file__).resolve().parentlog_dir = project_dir / "logs"log_dir.mkdir(parents=True, exist_ok=True)log_file = log_dir / "chat.log"logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", handlers=[ logging.StreamHandler(), logging.FileHandler(log_file, encoding="utf-8"), ],)logger = logging.getLogger(__name__)logger.info("日志系统已启动 log_file=%s", log_file.resolve())
这里使用 Path(__file__).resolve().parent 找到入口脚本所在目录。因此,如果 main.py 位于项目根目录,日志就会保存在项目根目录下的 logs/chat.log,不会因为从不同终端目录启动程序而改变位置。
FileHandler 不会自动创建父目录,所以必须先调用 log_dir.mkdir()。它默认以追加模式写入,程序重新启动后,新日志会接在旧日志后面。如果只想写入文件,可以删掉 logging.StreamHandler();如果不配置 FileHandler,就不会产生日志文件。
这个示例适合课程练习。长期运行的服务还要考虑日志轮转,否则单个文件会不断增大;后续可以使用 logging.handlers.RotatingFileHandler 按文件大小保留有限数量的历史日志。
10. 记录异常
只记录“读取失败”通常还不够,我们还需要知道错误出现在哪一行。在 except 代码块中调用 logger.exception(),日志会自动附带 Traceback:
def load_session(path): try: return path.read_text(encoding="utf-8") except OSError: logger.exception("读取会话失败 path=%s", path) raise
logger.exception() 应该在处理异常的 except 代码块中使用。
这里记录后仍然执行 raise,让上层代码决定如何提示用户或恢复操作。把错误写进日志,并不等于错误已经处理完毕。
不要在日志中记录以下内容:
可以记录消息数量、会话 ID、模型名称和错误类型等诊断信息,但应遵循最小必要原则。
11. 从环境变量读取配置
模型名称、历史记录上限和 API Key 等配置,不应该散落在源码中。环境变量是操作系统在程序运行时提供的键值对,可以让同一份代码在不同电脑或部署环境中使用不同配置。
11.1 直接读取系统环境变量
读取可选值:
import osmodel_name = os.getenv("CHAT_MODEL", "demo-model")
os.getenv("CHAT_MODEL", "demo-model") 会读取 CHAT_MODEL;如果没有设置,就返回第二个参数提供的默认值。
在 macOS 或 Linux 的终端中,可以先临时设置变量,再启动当前程序:
export CHAT_MODEL="echo-model"python3 main.py
在 Windows PowerShell 中可以写:
$env:CHAT_MODEL = "echo-model"py main.py
这些设置只影响当前终端会话,以及由这个终端启动的程序。关闭终端后配置是否保留,取决于是否将它写入系统或终端的配置文件。
读取必填配置时,还要检查它是否有效:
api_key = os.getenv("AI_API_KEY")if api_key is None or not api_key.strip(): raise ValueError("缺少环境变量 AI_API_KEY")
环境变量没有“整数类型”:只要变量存在,os.getenv() 读到的就是字符串。因此,整数配置既要转换类型,也要检查取值范围:
max_history_text = os.getenv("MAX_HISTORY", "50")try: max_history = int(max_history_text)except ValueError as error: raise ValueError("MAX_HISTORY 必须是整数") from errorif max_history <= 0: raise ValueError("MAX_HISTORY 必须大于 0")
11.2 使用 python-dotenv 读取 .env
每次都在终端中手动设置环境变量并不方便。在本地开发中,项目通常会把模型名称、模型 Token 和其他配置写入项目根目录的 .env 文件,再在程序启动时加载。
.env 不是 Python 标准库能够自动识别的文件。这里使用第三方包 python-dotenv 读取它,先安装依赖:
python -m pip install python-dotenv
一个简单的项目结构如下:
chat_project/├── .env├── .env.example├── .gitignore└── main.py
在 .env 中写入本机使用的配置:
AI_API_KEY=your-model-token-hereCHAT_MODEL=demo-modelMAX_HISTORY=50
等号两侧通常不需要空格,值也不必全部加引号。Token 中如果包含特殊字符,或值的首尾需要保留空格,可以使用引号包裹。
在 main.py 启动时先调用 load_dotenv(),再使用 os.getenv():
import osfrom pathlib import Pathfrom dotenv import load_dotenvproject_dir = Path(__file__).resolve().parentenv_file = project_dir / ".env"load_dotenv(dotenv_path=env_file)api_key = os.getenv("AI_API_KEY")model_name = os.getenv("CHAT_MODEL", "demo-model")max_history_text = os.getenv("MAX_HISTORY", "50")if api_key is None or not api_key.strip(): raise ValueError("缺少环境变量 AI_API_KEY")
不传路径时,load_dotenv() 会自动查找 .env。课程示例显式传入 project_dir / ".env",可以让文件位置更加清楚,也避免程序从不同目录启动时产生疑问。
默认情况下,.env 不会覆盖操作系统中已经存在的同名环境变量。这样,部署平台设置的真实环境变量可以优先于本地文件。除非确实需要改变优先级,否则不要随意使用 override=True。
load_dotenv() 只负责把文件内容加载到进程环境中,读取后的值仍然都是字符串。整数转换、必填检查和取值范围验证仍然要由程序完成。
11.3 提供不含密钥的 .env.example
真实 .env 只属于本地环境,不应该提交到 Git。将它加入 .gitignore:
.env.env.local
为了让其他开发者知道项目需要哪些配置,可以提交一份 .env.example:
AI_API_KEY=CHAT_MODEL=demo-modelMAX_HISTORY=50
.env.example 只保留变量名和安全的示例值,不能包含任何真实 Token。其他开发者可以复制它并创建自己的 .env。
12. 环境变量与密钥安全
把密钥放进环境变量,可以避免它直接出现在源码中,但并不意味着密钥从此绝对安全。仍然要遵守以下原则:
- • 将
.env 加入 .gitignore,不把密钥提交到 Git。 - •
.env.example 只保留变量名和安全的示例值。
.env 是明文文件,主要适合本地开发。部署到服务器或云平台时,优先使用平台提供的环境变量或密钥管理服务,不要把本地 .env 直接复制到公共镜像或代码仓库。
13. 带标识和时间的消息模型
上一节使用 default_factory=list 为每个会话创建独立的列表。同样的机制也能确保每条消息在创建时获得新的 ID 和时间:
from dataclasses import dataclass, fieldfrom datetime import datetime, timezonefrom uuid import uuid4def create_id() -> str: return str(uuid4())def utc_now() -> datetime: return datetime.now(timezone.utc)@dataclassclass Message: role: str content: str message_id: str = field(default_factory=create_id) created_at: datetime = field(default_factory=utc_now) def __post_init__(self) -> None: self.role = self.role.strip().lower() self.content = self.content.strip() if self.role not in ("system", "user", "assistant"): raise ValueError("消息角色无效") if not self.content: raise ValueError("消息内容不能为空") def to_dict(self) -> dict[str, str]: return { "message_id": self.message_id, "role": self.role, "content": self.content, "created_at": self.created_at.isoformat(), }
这里不能在字段默认值中直接写 str(uuid4()) 或提前取得当前时间,原因是类体只执行一次,那样得到的值会被后续实例重复使用。使用 default_factory 后:
- • 每次创建
Message,都会重新调用 create_id()。 - • 每次创建
Message,也会重新调用 utc_now()。
这样,不同消息会拥有不同标识,时间也能反映各自的创建时刻。
14. 把标准库工具整合进对话核心
import loggingfrom collections import Counterfrom dataclasses import dataclass, fieldfrom uuid import uuid4logger = logging.getLogger(__name__)def create_id() -> str: return str(uuid4())@dataclassclass ChatSession: title: str session_id: str = field(default_factory=create_id) messages: list[Message] = field(default_factory=list) def add_message(self, message: Message) -> None: self.messages.append(message) logger.info( "消息已加入 session_id=%s role=%s message_count=%s", self.session_id, message.role, len(self.messages), ) def role_counts(self) -> dict[str, int]: counts = Counter( message.role for message in self.messages ) return dict(counts) def recent_messages(self, limit: int = 5) -> list[Message]: if limit <= 0: raise ValueError("limit 必须大于 0") return self.messages[-limit:].copy()
ChatSession 负责维护会话数据并统计消息。模型客户端与聊天服务继续沿用上一节建立的职责划分:
class ModelClient: def generate(self, prompt: str) -> str: raise NotImplementedError("子类必须实现 generate()")class EchoModelClient(ModelClient): def __init__(self, model_name: str): self.model_name = model_name def generate(self, prompt: str) -> str: logger.info( "生成模拟回答 model=%s prompt_length=%s", self.model_name, len(prompt), ) return f"模拟回复:{prompt}"class ChatService: def __init__( self, session: ChatSession, model_client: ModelClient,) -> None: self.session = session self.model_client = model_client def ask(self, question: str) -> str: user_message = Message("user", question) self.session.add_message(user_message) answer = self.model_client.generate(user_message.content) self.session.add_message(Message("assistant", answer)) return answer
最后,在入口函数中读取配置、创建对象,再将它们组装起来:
import loggingimport osfrom pathlib import Pathfrom dotenv import load_dotenvdef main() -> None: project_dir = Path(__file__).resolve().parent load_dotenv(dotenv_path=project_dir / ".env") log_dir = project_dir / "logs" log_dir.mkdir(parents=True, exist_ok=True) log_file = log_dir / "chat.log" logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", handlers=[ logging.StreamHandler(), logging.FileHandler(log_file, encoding="utf-8"), ], ) model_name = os.getenv("CHAT_MODEL", "echo-model") session = ChatSession("标准库练习") client = EchoModelClient(model_name) service = ChatService(session, client) logger.info("应用已启动 log_file=%s", log_file.resolve()) answer = service.ask("标准库有什么作用?") print(answer) print(session.role_counts())if __name__ == "__main__": main()
到这里,对话核心已经具备:
- • 通过
.env 或系统环境变量提供的外部配置。
这些标准库工具没有打乱对象原有的职责,而是各自解决时间、标识、统计、日志和配置问题。下一步,可以继续使用上一章学过的 JSON 模块,保存 Message.to_dict() 的结果。
常见错误
跨系统交换数据时使用朴素时间
缺少时区信息会产生歧义。后端数据优先使用带时区的 UTC 时间。
把 UUID 当作安全令牌
标识与授权是不同问题。即使 ID 难以猜测,也必须单独检查访问权限。
在每个模块重复调用 basicConfig()
日志配置通常在程序入口完成一次,业务模块只获取 logger。
以为日志会自动保存到文件
basicConfig() 在没有配置文件处理器时,默认只把日志输出到终端的标准错误流。需要持久保存时,必须显式配置 FileHandler,并提前创建日志目录。
使用不明确的相对日志路径
logging.FileHandler("chat.log") 会把文件写到当前工作目录,而当前工作目录取决于程序从哪里启动。可以基于 __file__ 构造稳定路径,并通过 resolve() 确认最终位置。
在日志中输出密钥或完整对话
只记录诊断所需的最小上下文,并对敏感字段做删除或遮盖。
创建 .env 后没有调用 load_dotenv()
os.getenv() 只读取进程中已有的环境变量。使用 .env 时,要先安装 python-dotenv,并在读取配置前调用 load_dotenv()。
把真实 .env 提交到 Git
.env 中通常包含模型 Token 等敏感信息,应该加入 .gitignore。团队只共享不含真实密钥的 .env.example。
动手练习
练习 1:消息标识与时间
为 Message 增加 UUID 字符串和 UTC 时间,创建三条消息,确认 ID 不同且时间包含时区。实现 to_dict() 并导出 JSON。
练习 2:统计与最近记录
使用:
- •
deque(maxlen=5) 保存最近五条命令。 - •
defaultdict(list) 按角色分组消息。
分别说明这些容器为什么比手动维护普通数据结构更合适。
练习 3:日志与环境配置
使用 python-dotenv 从 .env 加载 AI_API_KEY、CHAT_MODEL 和 MAX_HISTORY:
- • 安装
python-dotenv,并在读取变量前调用 load_dotenv()。 - • 创建
.env、.env.example 和 .gitignore,确保真实 .env 不会被提交。 - • 缺少模型名称时使用安全的模拟默认值,缺少 Token 时给出明确错误。
- • 创建
logs/ 目录,把日志同时输出到终端和 logs/chat.log。 - • 启动、生成、保存和异常操作使用合适的日志级别。
- • 启动时记录日志文件的绝对路径,确认文件实际保存位置。
随堂小测
- 3.
isoformat() 和 fromisoformat() 分别做什么? - 4. UUID 为什么比列表索引更适合作为持久标识?
- 6.
deque(maxlen=3) 加入第四项时发生什么? - 7. 日志与面向用户的
print() 有什么区别? - 8. 只调用
basicConfig() 且不配置文件处理器时,日志默认输出到哪里?会自动生成日志文件吗? - 9. 为什么创建
FileHandler 前通常要先创建日志目录? - 10.
logger.exception() 应在什么位置使用? - 11.
os.getenv() 返回的环境变量是什么类型? - 12. 为什么
.env 文件不会自动被 os.getenv() 读取?应该怎样加载? - 13. 默认情况下,系统环境变量和
.env 中出现同名变量时,哪个值优先? - 14.
.env 和 .env.example 分别应该保存什么?
参考答案
- 1. 标准库随 Python 提供;第三方包通常需要单独安装到环境中。
- 2. UTC 避免不同地区的本地时间歧义,带时区信息才能可靠转换。
- 3. 前者把时间转成标准字符串,后者从兼容字符串恢复时间对象。
- 4. 索引会随删除和排序改变,UUID 可以作为独立、低碰撞的长期标识。
- 7.
print() 面向用户或临时观察;日志记录带级别和上下文的程序事件。 - 8. 默认输出到终端的标准错误流,不会自动生成日志文件。
- 9.
FileHandler 可以创建日志文件,但不会创建缺失的父目录;目录不存在时会引发错误。 - 11. 字符串;未设置且无默认值时得到
None。 - 12. 标准库只读取进程环境,不会主动解析项目文件;可以先使用
python-dotenv 的 load_dotenv() 加载 .env。 - 13. 已存在的系统环境变量优先;
load_dotenv() 默认不会用 .env 覆盖它。 - 14.
.env 保存本机实际配置且不能提交;.env.example 只保存变量名和安全示例,可以提交给团队参考。