- 🍨 本文为🔗365天深度学习训练营 中的学习记录博客
- 🍖 原作者:K同学啊
Day 1Pydantic 数据建模基础
Step 0:Pydantic 是什么
一、什么是 Pydantic
Pydantic 是 Python 非常流行的数据校验 + 数据解析库,利用 Python 类型提示 (type‑hint) 做校验。
核心口号:Data validation using Python type hints
FastAPI 的底层数据模型就是基于 Pydantic。
二、核心作用
- 数据校验:检查传入数据类型、范围、是否必填、格式(邮箱、日期),错了自动抛异常
- 类型转换(解析):尽量把输入值转成你声明的类型
- 序列化 / 反序列化:dict/json ↔ Python 对象互转
- 自动生成 JSON Schema(接口文档用)
⚠️ Pydantic 不是 ORM,不直接读写数据库。
三、两个大版本区别(重点!)
- Pydantic v1 :
from pydantic import BaseModel - Pydantic v2 (2023 后新版,性能提升 5‑100 倍):
- 默认:
from pydantic import BaseModel - 底层引擎换成 Rust 写的
pydantic‑core - 部分 API 有 breaking change
Pydantic = Python 里「带自动类型校验的数据类」------你定义字段类型和约束,构造时自动校验,错了立即报错。
.类比其他语言:
● Java:POJO + 校验注解(@NotNull / @Min / @Pattern)
● TypeScript:interface + 编译时类型检查
● Pydantic = Python 的等价物,但更轻(不需要编译)
- 默认:
W1 你的旧方法(用 dict 装数据):
python
exp = {
"experiment_id": "exp_001",
"lr": 0.001,
"metrics": [{"name": "mAP", "value": 0.42}],
"status": "success",
}
问题: 1. 字段拼错不报错:exp"experimnt_id" → KeyError 在运行时才出现 2. 类型不强制:exp"lr" = "0.001"(字符串!)也能存 # 3. 嵌套结构没约束:exp"metrics"0"valu" = 0.5(拼错)不会发现 4. 文档不内嵌:字段含义只能写在外部 README 里 #5. 序列化/反序列化手写:json.dumps / json.loads 自己处理
W2 你的新方法(用 Pydantic Model):
python
from pydantic import BaseModel
class ExperimentRecord(BaseModel):
experiment_id: str
lr: float
metrics: list[dict]
status: str
exp = ExperimentRecord(
experiment_id="exp_001",
lr=0.001,
metrics=[{"name": "mAP", "value": 0.42}],
status="success",
)
5 个维度的对比:
维度 dict(散装) Pydantic Model(集装箱)
字段拼错 运行时 KeyError 构造时 ValidationError
类型强制 不强制(lr="0.001" OK) 自动转换 + 强制
嵌套校验 不校验(dict 里随便嵌) 递归校验所有层级
文档 写在外部 README 写在字段 description 里
序列化 手写 json.dumps 一行 .model_dump_json()
一句话对比:
dict 装数据像「散装快递」------全靠你自己记字段名。
Pydantic Model 装数据像「集装箱」------标签贴在箱子上,错了不让你装车。
本教程全部用 Pydantic v2 语法。
如果你的旧代码报 model_validator 不存在 = 装的是 v1,升级:pip install --upgrade "pydantic>=2.0"
📦 第 1 步:安装 Pydantic(2 分钟)
Week 02 需要 Pydantic v2。
pip install "pydantic>=2.0"
Python 3.9 用户额外需要装 eval_type_backport------让 Pydantic 内部能解析 PEP 604 union 字符串(3.10+ 不需要):
pip install eval_type_backport
💡 为什么需要这个包:from future import annotations 只让 Python 解释器延迟解析注解,但 Pydantic v2 内部会主动 eval() 注解字符串------Python 3.9 不识别 PEP 604 union 语法(str | int)就会报 TypeError: Unable to evaluate type annotation。
eval_type_backport 给 3.9 装上 PEP 604 的解析能力------3.10+ 不需要是因为 Python 自己支持。
3 种解决方式(任选一种):
- 装 eval_type_backport(推荐,不改任何代码)
- 改用 Unionstr, int, float, bool 写法(不装包,但代码更啰嗦)
- 升级 Python 到 3.10+(最干净,但需要重装环境)
验证:
python3 -c "import pydantic; print(f'Pydantic {pydantic.VERSION}')"
应该看到 Pydantic 2.x.x。
💡 知识卡:Pydantic 在「科研工程自动化」里到底扮演什么角色?
你可能觉得"数据建模"是个抽象概念。让我把它拉到地面------你的科研工作里,哪些场景会用到 Pydantic:
一句话: Pydantic 是「把你的科研工作数据化」的接口层。
第 2 步:第一个 schema:实验记录(15 分钟)
科研项目最常见的"非结构化数据"是实验日志:你跑完 baseline,日志里是这样的:
2026-07-01 10:23:45 Starting training...
2026-07-01 10:23:46 Loading dataset: VisDrone
2026-07-01 10:24:12 Epoch 1/100: loss=2.43 mAP=0.05
...
2026-07-01 11:05:33 Training finished. Best mAP=0.421
2026-07-01 11:05:33 Total runtime: 41 min 48 sec
创建 models.py:
python
# models.py
# Week 02 Day 1 · 科研项目核心数据 schema
# 这一行让 Python 3.9 也能跑 W2 的 PEP 604 union 语法(`str | int`)
# Python 3.10+ 不需要这一行,但加上没坏处------两种版本都兼容
from __future__ import annotations
from pydantic import BaseModel, Field, model_validator
from typing import Optional
from enum import Enum
from datetime import datetime
class ExperimentStatus(str, Enum):
"""实验状态"""
PENDING = "pending"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
CRASHED = "crashed"
class MetricResult(BaseModel):
"""单个评估指标结果"""
name: str = Field(..., description="指标名,如 mAP / accuracy / F1")
value: float = Field(..., description="指标值")
higher_is_better: bool = Field(default=True, description="越大越好还是越小越好")
class HardwareConfig(BaseModel):
"""实验运行的硬件配置"""
gpu_model: Optional[str] = Field(None, description="GPU 型号,如 A100 / 3090")
gpu_count: int = Field(default=1, ge=1, le=64, description="GPU 数量")
per_gpu_memory_gb: Optional[float] = Field(None, description="单卡显存 GB")
total_memory_gb: Optional[float] = Field(None, description="总显存 GB(= gpu_count * per_gpu_memory_gb)")
class ExperimentRecord(BaseModel):
"""一次实验的完整结构化记录"""
experiment_id: str = Field(..., description="实验唯一 ID,通常是时间戳或 hash")
started_at: datetime = Field(..., description="开始时间")
finished_at: Optional[datetime] = Field(None, description="结束时间")
status: ExperimentStatus = Field(..., description="实验状态")
# 配置快照(避免改 config.yaml 后历史记录丢失上下文)
model_name: str = Field(..., description="模型名 / 方法名")
dataset_name: str = Field(..., description="数据集名")
hyperparameters: dict[str, str | int | float | bool] = Field(
default_factory=dict, description="所有超参(lr / batch_size / epochs...)"
)
hardware: HardwareConfig = Field(default_factory=HardwareConfig, description="硬件配置")
# 结果
metrics: list[MetricResult] = Field(default_factory=list, description="评估结果")
log_path: Optional[str] = Field(None, description="原始日志文件路径")
notes: Optional[str] = Field(None, description="额外备注")
@model_validator(mode="after")
def check_consistency(self):
"""自动校验:成功必有 metrics,失败必有 reason"""
if self.status == ExperimentStatus.SUCCESS and not self.metrics:
raise ValueError("成功的实验必须有 metrics")
if self.status == ExperimentStatus.FAILED and self.finished_at is None:
raise ValueError("失败的实验应该有结束时间")
return self
if __name__ == "__main__":
# 合法示例,作者:K同学啊
exp = ExperimentRecord(
experiment_id="exp_2026_07_01_baseline",
started_at=datetime.fromisoformat("2026-07-01T10:23:45"),
finished_at=datetime.fromisoformat("2026-07-01T11:05:33"),
status=ExperimentStatus.SUCCESS,
model_name="YOLOv8m",
dataset_name="VisDrone",
hyperparameters={"lr": 0.001, "batch_size": 16, "epochs": 100},
hardware=HardwareConfig(gpu_model="A100", gpu_count=1, total_memory_gb=40),
metrics=[
MetricResult(name="mAP@0.5", value=0.421, higher_is_better=True),
MetricResult(name="loss", value=0.87, higher_is_better=False),
],
)
print("✓ 合法数据构造成功:")
print(exp.model_dump_json(indent=2))
# 非法示例:成功的实验没 metrics
try:
bad = ExperimentRecord(
experiment_id="bad_exp",
started_at=datetime.now(),
status=ExperimentStatus.SUCCESS,
model_name="X",
dataset_name="Y",
)
except Exception as e:
print("\n✗ 非法数据被拦截:")
print(e)
实验结果如下:

python
# paper_meta.py,作者:K同学啊
# Week 02 Day 1 · 论文元数据 schema(够填 Zotero 库)
from __future__ import annotations # Python 3.9 兼容 PEP 604 union
from pydantic import BaseModel, Field
from typing import Optional
from datetime import date
from enum import Enum
class PaperSection(BaseModel):
"""论文中的一节内容"""
heading: str = Field(..., description="节标题,如 'Introduction' / '3. Method'")
summary: str = Field(..., min_length=20, description="这一节的核心内容摘要")
key_points: list[str] = Field(default_factory=list, description="这一节的关键点")
class CodeLink(BaseModel):
"""论文里提到的代码/数据集链接"""
type: str = Field(..., description="代码 / 数据集 / 预训练权重 / demo")
url: str = Field(..., description="URL")
note: Optional[str] = Field(None, description="备注(如 'official' / 'third-party')")
class PaperMeta(BaseModel):
"""论文完整元数据(够自动填 Zotero)"""
# --- 基础字段(足以填一篇参考文献)---
title: str = Field(..., min_length=5)
authors: list[str] = Field(..., min_length=1)
year: int = Field(..., ge=1900, le=2100)
venue: str = Field(..., description="会议或期刊名,简称即可(NeurIPS / CVPR)")
venue_type: str = Field(..., description="conference / journal / preprint / workshop")
published_date: Optional[date] = Field(None, description="准确发表日期")
doi: Optional[str] = Field(None, description="DOI(如 10.1109/CVPR.2017.123)")
arxiv_id: Optional[str] = Field(None, description="arXiv ID(如 1706.03762)")
# --- 工程相关的字段(研究自动化用)---
abstract: str = Field(..., min_length=50, description="摘要原文")
keywords: list[str] = Field(default_factory=list)
sections: list[PaperSection] = Field(default_factory=list, description="论文各节的结构化摘要")
code_links: list[CodeLink] = Field(default_factory=list, description="代码/数据集链接")
cited_by_count: Optional[int] = Field(None, ge=0, description="引用数")
@property
def zotero_key(self) -> str:
"""生成 Zotero 引用的 sort key"""
first_author_lastname = self.authors[0].split()[-1] if self.authors else "Unknown"
return f"{first_author_lastname}{self.year}"
if __name__ == "__main__":
# 测试:手动填一篇经典论文
paper = PaperMeta(
title="Attention Is All You Need",
authors=["A. Vaswani", "N. Shazeer", "N. Parmar", "J. Uszkoreit"],
year=2017,
venue="NeurIPS",
venue_type="conference",
abstract="We propose a new simple network architecture, the Transformer...",
keywords=["attention", "transformer", "neural machine translation"],
code_links=[
CodeLink(type="code", url="https://github.com/tensorflow/tensor2tensor", note="official"),
],
)
print(f"Zotero key: {paper.zotero_key}")
print(paper.model_dump_json(indent=2))

第 4 步:第三个 schema:项目配置
创建 project_config.py:
python
# project_config.py
# Week 02 Day 1 · 项目配置 schema(够 LLM 读懂你的实验计划)
from __future__ import annotations # Python 3.9 兼容 PEP 604 union
from pydantic import BaseModel, Field, model_validator
from typing import Optional
from pathlib import Path
class ExperimentPlan(BaseModel):
"""一个实验计划"""
name: str = Field(..., description="实验名,如 'baseline' / 'ablation_no_attention'")
model: str = Field(..., description="要跑的模型")
dataset: str = Field(..., description="要用数据集")
metrics_to_track: list[str] = Field(..., min_length=1, description="要跟踪哪些指标")
expected_runtime_min: Optional[int] = Field(None, ge=0)
notes: Optional[str] = None
class ProjectConfig(BaseModel):
"""整个研究项目的配置(够 LLM 帮你写 README / 跑实验)"""
project_name: str = Field(..., description="项目名")
research_question: str = Field(..., description="这个项目要回答的科学问题")
datasets: list[str] = Field(..., min_length=1, description="用到的数据集")
models: list[str] = Field(..., min_length=1, description="要跑/对比的模型")
metrics: list[str] = Field(..., min_length=1, description="评估指标")
plans: list[ExperimentPlan] = Field(..., min_length=1, description="具体的实验计划")
output_dir: Path = Field(..., description="实验结果输出目录")
@model_validator(mode="after")
def check_plans_match_overview(self):
"""校验:plans 里的模型和数据集必须出现在 overview 里"""
dataset_set = set(self.datasets)
model_set = set(self.models)
for plan in self.plans:
if plan.dataset not in dataset_set:
raise ValueError(f"实验 {plan.name} 用到数据集 {plan.dataset} 但未在 datasets 列出")
if plan.model not in model_set:
raise ValueError(f"实验 {plan.name} 用到模型 {plan.model} 但未在 models 列出")
return self
if __name__ == "__main__":
cfg = ProjectConfig(
project_name="small-object-detection-benchmark",
research_question="什么方法在小目标检测上效果最好?",
datasets=["VisDrone", "DOTA"],
models=["YOLOv8m", "RT-DETR"],
metrics=["mAP@0.5", "mAP@0.75"],
plans=[
ExperimentPlan(
name="baseline_yolov8m",
model="YOLOv8m",
dataset="VisDrone",
metrics_to_track=["mAP@0.5", "mAP@0.75"],
expected_runtime_min=60,
),
ExperimentPlan(
name="baseline_rt_detr",
model="RT-DETR",
dataset="VisDrone",
metrics_to_track=["mAP@0.5", "mAP@0.75"],
expected_runtime_min=90,
),
],
output_dir=Path("./outputs"),
)
print("✓ 项目配置合法")
print(f" {len(cfg.plans)} 个实验计划")

Day 2 · 输出数据结构化
今天的任务:让 LLM 把这些「非结构化」变成「结构化」,可入库、可对比、可自动化处理。
打开 REPL 体验一下「不结构化」的痛:
python
# 假设你的实验日志是这样的(wandb 实际输出可能不一样)
log_text = """
[2026-07-01 10:23:45] Starting training...
[2026-07-01 10:23:46] Loading dataset: VisDrone (6485 images)
[2026-07-01 10:24:12] Config: model=YOLOv8m, lr=0.001, batch_size=16, epochs=100
[2026-07-01 11:05:33] Epoch 100/100: loss=0.87 mAP@0.5=0.421 mAP@0.75=0.281
[2026-07-01 11:05:33] Training finished. Total runtime: 41m48s
[2026-07-01 11:05:34] Saved best checkpoint to /workspace/exp/best.pt
"""
# 你要拿 mAP------怎么办?
import re
m = re.search(r"mAP@0\.5=([\d.]+)", log_text)
mAP = float(m.group(1)) # 0.421
# 如果 log 里写的是 mAP@50 不是 mAP@0.5 整个就崩了
# 如果日志在 GPU 节点上而你在这台机器上没 mount,整个流程断了
第 2 步:方案 A --- JSON Object + Pydantic 强校验(25 分钟)
python
# parse_log.py
# Week 02 Day 2 · 自动解析实验日志
import os
import sys
import json
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com",
)
from models import ExperimentRecord
# 一段真实的实验日志(wandb 风格)
SAMPLE_LOG = """
[2026-07-01 10:23:45] Starting training...
[2026-07-01 10:23:46] Loading dataset: VisDrone (6485 images, 10 classes)
[2026-07-01 10:24:12] Config: model=YOLOv8m, lr=0.001, batch_size=16, epochs=100, optimizer=AdamW
[2026-07-01 10:24:15] Hardware: 4x A100 (40GB each, total 160GB)
[2026-07-01 11:05:33] Epoch 100/100: train_loss=0.87 mAP@0.5=0.421 mAP@0.75=0.281
[2026-07-01 11:05:33] Training finished successfully.
[2026-07-01 11:05:33] Total runtime: 41m48s
[2026-07-01 11:05:34] Best checkpoint: /workspace/exp_baseline_yolov8m/best.pt
[2026-07-01 11:05:34] Notes: baseline run with default config
"""
def parse_log_native(log_text: str) -> ExperimentRecord:
"""方案 A:json_object 模式 + Pydantic 强校验
为什么不直接用 OpenAI 的 strict JSON Schema?
- DeepSeek 等国产模型不支持 `type: "json_schema"` 模式(只支持 `json_object`)
- 用 `json_object` 让 LLM 输出 JSON 字符串,再用 Pydantic 的 model_validate_json 强校验
- 校验失败时 Pydantic 抛 ValidationError,能被 instructor 库自动重试
"""
resp = client.chat.completions.create(
model="deepseek-chat",
messages=[
{
"role": "system",
"content": """你是一个实验日志解析助手。
从用户提供的实验日志中提取关键信息,**只输出一个 JSON 对象**(不要 ```代码块标记,不要任何解释文字)。
**必须严格按照这个 JSON 模板输出**(字段名 + 字段类型都不能错):
```json
{
"experiment_id": "exp_2026_07_01_yolov8m",
"started_at": "2026-07-01T10:23:45", // ISO 8601 字符串
"finished_at": "2026-07-01T11:05:33", // ISO 8601 字符串
"status": "success", // 必须是 "success" / "failed" / "running" 之一
"model_name": "YOLOv8m",
"dataset_name": "VisDrone",
"hyperparameters": { // dict:键是超参名,值是 str/int/float/bool
"lr": 0.001,
"batch_size": 16,
"epochs": 100,
"optimizer": "AdamW"
},
"hardware": { // 嵌套对象,不是 dict
"gpu_model": "A100",
"gpu_count": 4,
"per_gpu_memory_gb": 40,
"total_memory_gb": 160
},
"metrics": [ // ⚠️ 注意:这是 list(数组),不是 dict
{"name": "mAP@0.5", "value": 0.421, "higher_is_better": true},
{"name": "mAP@0.75", "value": 0.281, "higher_is_better": true},
{"name": "train_loss", "value": 0.87, "higher_is_better": false}
],
"log_path": null, // 字符串或 null
"notes": null // 字符串或 null
}
**特别处理**:
- 时间戳用 ISO 8601 格式(如 2026-07-01T10:23:45)
- 超参数(hyperparameters)全部列出来:model / lr / batch_size / epochs / optimizer 等
- **metrics 必须是 list of {name, value, higher_is_better}**------不是 dict
- `name`:指标名(mAP@0.5 / mAP@0.75 / loss / accuracy 等)
- `value`:指标值(float)
- `higher_is_better`:越大越好还是越小越好(mAP/accuracy → true,loss → false)
- status 必须是 success / failed / running 之一:
- 看到 "finished successfully" → success
- 看到 "crashed" / "OOM" / "killed" → failed
- 日志没结束 → running""",
},
{"role": "user", "content": f"实验日志:\n```\n{log_text}\n```"},
],
response_format={"type": "json_object"}, # DeepSeek 兼容
temperature=0.0,
)
# 拿到 JSON 字符串
json_str = resp.choices[0].message.content
# Pydantic 强校验:字段类型 / 必填 / Literal 全部兜底
record = ExperimentRecord.model_validate_json(json_str)
return record
if __name__ == "__main__":
if len(sys.argv) > 1:
# 从文件读取日志
log_path = sys.argv[1]
with open(log_path, "r", encoding="utf-8") as f:
log_text = f.read()
else:
log_text = SAMPLE_LOG
print(f"输入日志长度:{len(log_text)} 字符\n")
print("开始解析...\n")
record = parse_log_native(log_text)
print("✓ 解析成功:")
print(json.dumps(record.model_dump(mode="json"), indent=2, ensure_ascii=False))
# 持久化到磁盘:W2 Day 3 的 generate_report.py 会读这个文件
out_path = "outputs/week02/extracted.json"
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
f.write(json.dumps(record.model_dump(mode="json"), indent=2, ensure_ascii=False))
print(f"\n✓ 已保存到 {out_path}(Day 3 报告会读这个)")
print("\n字段访问测试:")
print(f" model_name = {record.model_name}")
print(f" status = {record.status.value}")
print(f" metrics = {[(m.name, m.value) for m in record.metrics]}")
输出
python
{
"experiment_id": "exp_2026_07_01_baseline",
"started_at": "2026-07-01T10:23:45",
"finished_at": "2026-07-01T11:05:33",
"status": "success",
"model_name": "YOLOv8m",
"dataset_name": "VisDrone",
"hyperparameters": {
"lr": 0.001,
"batch_size": 16,
"epochs": 100,
"optimizer": "AdamW"
},
"hardware": {
"gpu_model": "A100",
"gpu_count": 4,
"total_memory_gb": 160
},
"metrics": [
{"name": "mAP@0.5", "value": 0.421, ...},
{"name": "mAP@0.75", "value": 0.281, ...}
]
}
第 3 步:方案 B --- instructor 库
python
import instructor
from openai import OpenAI
# 用 instructor 包装 client
# 兼容性说明:instructor v1.0+ 支持任意 OpenAI-compatible 端点(DeepSeek / 智谱 / 月之暗面等),
# 用 `from_openai()` 包装自定义 base_url 的 client 即可。
instructor_client = instructor.from_openai(
OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com",
)
)
def parse_log_instructor(log_text: str) -> ExperimentRecord:
"""方案 B:用 instructor 库,一行搞定"""
record = instructor_client.chat.completions.create(
model="deepseek-chat",
messages=[
{
"role": "system",
"content": """你是K同学的实验日志解析助手。从实验日志中提取关键信息。同样的处理规则(ISO 时间戳、完整超参、metrics 列表)。""",
},
{"role": "user", "content": f"实验日志:\n```\n{log_text}\n```"},
],
response_model=ExperimentRecord,
temperature=0.0,
max_retries=3, # 失败自动重试,最多 3 次
)
return record
# 把现有 __main__ 块替换为对比测试版本
if __name__ == "__main__":
print("=" * 60)
print("方案 A:原生 JSON Schema")
print("=" * 60)
r1 = parse_log_native(SAMPLE_LOG)
print(json.dumps(r1.model_dump(mode="json"), indent=2, ensure_ascii=False))
print("\n" + "=" * 60)
print("方案 B:instructor 库")
print("=" * 60)
r2 = parse_log_instructor(SAMPLE_LOG)
print(json.dumps(r2.model_dump(mode="json"), indent=2, ensure_ascii=False))
print("\n✓ 两种方案输出应该字段相同")
第 4 步:实战 --- 处理你自己的真实实验日志(20 分钟)
找一个真实的实验日志(你自己的 / 同学的 / 公开 baseline 的),测一下解析器:
python
# test_real_log.py
from parse_log import parse_log_instructor
import json
REAL_LOGS = [
"outputs/week02/my_experiment.txt", # 换成你真实的日志路径
"outputs/week02/another_exp.txt",
]
for path in REAL_LOGS:
print(f"\n{'=' * 60}")
print(f"### {path}")
print(f"{'=' * 60}")
try:
with open(path, "r", encoding="utf-8") as f:
log_text = f.read()
record = parse_log_instructor(log_text)
print(json.dumps(record.model_dump(), indent=2, ensure_ascii=False))
except FileNotFoundError:
print(f"(文件不存在:{path})")
except Exception as e:
print(f"✗ 解析失败:{e}")
第 5 步:当 JSON Schema 不够灵活时怎么办
python
# 场景 1:超参数量不固定,无法穷举
hyperparameters: dict # ✓ OK(JSON Schema 支持 dict)
# 场景 2:日志里有你没预料到的字段(比如自定义 metric)
{"my_custom_score": 0.42, "weird_metric_name": 0.8}
# → 你想让 LLM 自由发挥,但又想保证基本结构
# 场景 3:超长日志超 token 限制
# 10000 行的训练日志 > LLM context window
Day 3 · Streaming 流式输出
第 1 步:科研文档的「长文本痛」
流式输出(streaming)就是为这个场景设计的:
python
非流式:
用户:「写一份实验报告」
↓ 等 60 秒
屏幕:一次性刷出 3000 字
流式:
用户:「写一份实验报告」
↓ 等 0.5 秒
屏幕:第一个字出现
屏幕:第二个字出现
...
(你能边看边判断方向对不对)
Day 3 的核心问题: 让你能在 LLM 写大文档时实时看到进度 + 边看边保存。
打开 REPL 体验一下非流式 vs 流式的延迟:
python
import os
import time
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com",
)
QUESTION = "用 5 段话介绍你的研究方向"
# 非流式:等所有内容生成完才一次返回
t0 = time.time()
resp = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": QUESTION}],
)
print(f"\n[非流式] 等 {time.time() - t0:.1f}s 才看到全部文字")
print(resp.choices[0].message.content)
# 流式:第一个字生成完就立刻返回,后续 chunk 持续到达
# ⚠️ 这里的 chunk 是什么?看完这个跑通后看第 3 步的详细解释
t0 = time.time()
print(f"\n[流式] 立刻开始:", end="", flush=True)
stream = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": QUESTION}],
stream=True,
)
first_token_at = None
for chunk in stream:
content = chunk.choices[0].delta.content
if content:
if first_token_at is None:
first_token_at = time.time() - t0
print(f"\n[流式] 首字延迟 {first_token_at:.1f}s,之后逐字输出:\n")
print(content, end="", flush=True)
print(f"\n\n[流式] 总耗时 {time.time() - t0:.1f}s")
第 2 步:第一个流式写入文件
python
# stream_to_file.py
# Week 02 Day 3 · 流式生成 + 同步写入文件
import os
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com",
)
def stream_to_file(prompt: str, output_path: str) -> str:
"""流式生成文本,同时显示在终端 + 写入文件"""
stream = client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": "你是一个科研写作助手,擅长写技术文档。"},
{"role": "user", "content": prompt},
],
temperature=0.7,
stream=True,
)
# 关键:边收边写
full_text = ""
print("\n" + "=" * 60)
print(f"正在生成 → {output_path}")
print("=" * 60 + "\n")
with open(output_path, "w", encoding="utf-8") as f:
for chunk in stream:
content = chunk.choices[0].delta.content
if content:
# 写到终端(实时显示)
print(content, end="", flush=True)
# 写到文件(同步保存,不等全部完成)
f.write(content)
# 累积完整文本(万一要后续处理)
full_text += content
print("\n\n" + "=" * 60)
print(f"✓ 已写入 {output_path} ({len(full_text)} 字符)")
print("=" * 60 + "\n")
return full_text
if __name__ == "__main__":
prompt = "写一份 300 字的科研项目 README,包含:项目目标、当前进度、技术栈、运行方式、未来计划"
output_path = "outputs/week02/auto_generated_readme.md"
os.makedirs(os.path.dirname(output_path), exist_ok=True)
stream_to_file(prompt, output_path)
第 3 步:深入理解 chunk
最朴素的理解------把 LLM 想象成主播在直播:
python
非流式 API:
你发请求 → 等 60 秒 → 一次性刷出 3000 字
类比:你在看录播,UP 主录好视频一次性上传
流式 API:
你发请求 → 0.5s 后看到第一个字 → 边看边来
类比:你在看直播,UP 主边想边打字,你这边跟着看
亲手看 chunk 的结构
python
import os
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com",
)
stream = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": "Hi"}],
stream=True,
)
for i, chunk in enumerate(stream):
if i >= 3:
break
print(f"\n=== Chunk {i} ===")
print(f" 类型: {type(chunk).__name__}")
print(f" delta.content = {chunk.choices[0].delta.content!r}")
print(f" delta.role = {chunk.choices[0].delta.role!r}")
print(f" finish_reason = {chunk.choices[0].finish_reason!r}")
# 再看最后一个 chunk(消费完整个流)
for chunk in stream:
pass
print(f"\n=== 最后一个 chunk ===")
print(f" delta.content = {chunk.choices[0].delta.content!r}")
print(f" finish_reason = {chunk.choices[0].finish_reason!r}")
第 4 步:实战 --- 流式生成实验报告(30 分钟)
python
# generate_report.py
# Week 02 Day 3 · 流式生成实验报告
import os
import json
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com",
)
SYSTEM_PROMPT = """你是一个科研报告写作助手。
任务:根据用户提供的实验数据,自动生成结构清晰的实验报告。
报告结构(Markdown):
1. **实验概述** ------ 一句话总结这次实验的目标和方法
2. **配置详情** ------ 模型 / 数据集 / 关键超参
3. **结果分析** ------ 主要指标的解读(好/坏/对比 baseline)
4. **改进建议** ------ 2-3 条下一步可尝试的方向
风格:
- 中文,专业但不死板
- 用表格 / 列表让数据清晰
- 不要编造数据,只描述提供的信息"""
def generate_report(experiment_data: dict, output_path: str):
"""流式生成实验报告 + 同步保存"""
user_prompt = f"""请基于以下实验数据生成实验报告:
```json
{json.dumps(experiment_data, indent=2, ensure_ascii=False, default=str)}
```"""
stream = client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.5,
stream=True,
)
# 边生成边写
with open(output_path, "w", encoding="utf-8") as f:
for chunk in stream:
content = chunk.choices[0].delta.content
if content:
print(content, end="", flush=True)
f.write(content)
print(f"\n\n✓ 报告已保存到 {output_path}\n")
if __name__ == "__main__":
# 读取 W2 Day 2 解析出来的实验记录
with open("outputs/week02/extracted.json", "r", encoding="utf-8") as f:
experiment_data = json.load(f)
output_path = "outputs/week02/REPORT.md"
os.makedirs(os.path.dirname(output_path), exist_ok=True)
generate_report(experiment_data, output_path)
Day 4 · Function Calling + Phase 1 项目
第 1 步:科研工程工具的"日常需求"
Function Calling 给你的能力:让 LLM 自主决定用哪些工具、按什么顺序
python
你: "我的实验目录里有什么文件?"
↓
LLM: 调用 list_dir("./outputs")
↓
LLM 看到:log.txt, results.csv, README.md, ...
↓
LLM: 自主决定接下来调 read_file("log.txt")
↓
LLM: 用得到的信息回答你
第 2 步:定义 4 个科研工程工具
python
# tools.py
# Week 02 Day 4 · 科研工程工具定义
import os
import re
from pathlib import Path
from typing import Optional
# ============================================
# 工具 1:列目录
# ============================================
def list_dir(path: str, pattern: Optional[str] = None) -> str:
"""
列出目录下的所有文件 / 子目录。
Args:
path: 目录路径,相对或绝对都可以
pattern: 可选的文件名过滤(如 "*.log" 只列 log 文件)
Returns:
文件列表,每行一个,含类型标记(FILE / DIR)
"""
p = Path(path).expanduser()
if not p.exists():
return f"错误:路径不存在: {path}"
if not p.is_dir():
return f"错误:不是目录: {path}"
items = []
for item in sorted(p.iterdir()):
prefix = "[DIR]" if item.is_dir() else "[FILE]"
name = item.name
size = "" if item.is_dir() else f" ({item.stat().st_size} bytes)"
items.append(f"{prefix} {name}{size}")
if pattern:
# 简单 glob
items = [i for i in items if re.search(pattern.replace("*", ".*"), i)]
return "\n".join(items) if items else "(空目录)"
# ============================================
# 工具 2:读文件
# ============================================
def read_file(path: str, max_lines: int = 100, start_line: int = 0) -> str:
"""
读文件的指定行范围。
Args:
path: 文件路径
max_lines: 最多读几行(避免 token 超限,默认 100)
start_line: 从第几行开始(默认 0)
Returns:
文件内容(最多 max_lines 行)
"""
p = Path(path).expanduser()
if not p.exists():
return f"错误:文件不存在: {path}"
if not p.is_file():
return f"错误:不是文件: {path}"
try:
with open(p, "r", encoding="utf-8") as f:
lines = f.readlines()
total = len(lines)
end = start_line + max_lines
chunk = lines[start_line:end]
header = f"[文件 {path}: 共 {total} 行, 显示第 {start_line+1}-{min(end, total)} 行]"
return header + "\n" + "".join(chunk)
except UnicodeDecodeError:
return f"错误:非 UTF-8 文件(可能是二进制):{path}"
# ============================================
# 工具 3:在文件里搜内容
# ============================================
def grep_files(pattern: str, path: str = ".", file_glob: str = "*") -> str:
"""
在指定目录下搜内容(类似 grep)。
Args:
pattern: 要搜的正则表达式
path: 目录路径
file_glob: 文件类型过滤(如 "*.py" 只搜 Python 文件)
Returns:
匹配行(路径:行号:内容)
"""
p = Path(path).expanduser()
if not p.exists():
return f"错误:路径不存在: {path}"
results = []
# 防御 1:pattern 太长 → LLM 拼了个超长正则
if len(pattern) > 200:
return "错误:正则表达式太长(>200 字符),可能恶意"
# 防御 2:pattern 含灾难性回溯特征
if re.search(r"\(.+\)\+|\(.+\)\*", pattern) and "?" not in pattern:
return "错误:正则含灾难性回溯风险(如 (a+)+),请改写"
try:
regex = re.compile(pattern)
except re.error as e:
return f"错误:正则表达式无效: {e}"
for file_path in p.rglob(file_glob):
if not file_path.is_file():
continue
# 跳过二进制和大文件
if file_path.stat().st_size > 1_000_000:
continue
try:
with open(file_path, "r", encoding="utf-8") as f:
for line_no, line in enumerate(f, 1):
if regex.search(line):
results.append(f"{file_path}:{line_no}: {line.rstrip()}")
if len(results) >= 50:
return "\n".join(results) + "\n...(超过 50 条,截断)"
except (UnicodeDecodeError, PermissionError):
continue
return "\n".join(results) if results else "(无匹配)"
# ============================================
# 工具 4:数行数 / 数 token
# ============================================
def count_lines(path: str) -> str:
"""
数文件总行数 / 字符数 / 估算 token 数。
Args:
path: 文件路径
Returns:
统计结果字符串
"""
p = Path(path).expanduser()
if not p.exists() or not p.is_file():
return f"错误:文件不存在: {path}"
with open(p, "r", encoding="utf-8") as f:
content = f.read()
lines = content.count("\n") + 1
chars = len(content)
# 粗估:英文 ~4 字符/token,中文 ~1.5 字符/token;混合文档取中间值 ~3
# 想精确用 tiktoken.encode()(Day 1 装过)
est_tokens = chars // 3
return f"行数: {lines}, 字符: {chars}, 估算 token: {est_tokens}"
# ============================================
# 工具 schema(告诉 LLM 怎么用)
# ============================================
TOOLS_SCHEMA = [
{
"type": "function",
"function": {
"name": "list_dir",
"description": "列出目录下的文件和子目录。用来探索项目结构、找实验结果文件、找代码文件。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "目录路径"},
"pattern": {"type": "string", "description": "可选的文件名 glob 过滤,如 '*.log'", "default": None},
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "读取文件内容(可指定行范围)。适合看日志、配置、小代码文件。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "文件路径"},
"max_lines": {"type": "integer", "description": "最多读几行", "default": 100, "minimum": 1},
"start_line": {"type": "integer", "description": "从第几行开始(0 索引)", "default": 0, "minimum": 0},
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "grep_files",
"description": "在目录下用正则搜内容,返回匹配行(含文件路径和行号)。适合找代码调用、配置项、错误关键词。",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "正则表达式"},
"path": {"type": "string", "description": "目录路径", "default": "."},
"file_glob": {"type": "string", "description": "文件类型过滤(glob),如 '*.py'", "default": "*"},
},
"required": ["pattern"],
},
},
},
{
"type": "function",
"function": {
"name": "count_lines",
"description": "统计文件的行数、字符数、估算 token 数。用于评估文件大小,决定怎么读。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "文件路径"},
},
"required": ["path"],
},
},
},
]
TOOL_FUNCTIONS = {
"list_dir": list_dir,
"read_file": read_file,
"grep_files": grep_files,
"count_lines": count_lines,
}
if __name__ == "__main__":
# 测试工具本身
print("工具测试:")
print(f" list_dir('./') = \n{list_dir('.')[:200]}...")
print(f" count_lines('models.py') = {count_lines('models.py')}")
print(f" grep_files('def ', '.', '*.py') = \n{grep_files('^def ', '.', '*.py')[:200]}...")
第 3 步:让 LLM 自动用工具
python
# agent_with_tools.py
# Week 02 Day 4 · LLM 自主调用工具
import os
import json
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com",
)
from tools import TOOLS_SCHEMA, TOOL_FUNCTIONS
SYSTEM_PROMPT = """你是一个科研工程助手,能用工具读文件、列目录、搜内容。
工作规则:
1. 先 list_dir 或 grep_files 探索,再用 read_file 看具体内容
2. 不要瞎猜文件路径 ------ 用工具确认
3. 找到信息后用中文回答,不要重复文件内容"""
def run_agent(user_input: str) -> str:
"""完整的多步工具调用循环"""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_input},
]
# 最多 8 轮(防止死循环)
for round_num in range(8):
resp = client.chat.completions.create(
model="deepseek-chat",
messages=messages,
tools=TOOLS_SCHEMA,
tool_choice="auto",
)
message = resp.choices[0].message
messages.append(message)
# 没调工具 → 输出最终回答
if not message.tool_calls:
return message.content
# 调了工具 → 执行 + 喂回结果
print(f"\n[LLM 第 {round_num+1} 轮:]"
f" {len(message.tool_calls)} 个工具调用")
for tool_call in message.tool_calls:
func_name = tool_call.function.name
func_args = json.loads(tool_call.function.arguments)
print(f" → {func_name}({json.dumps(func_args, ensure_ascii=False)})")
# 安全防护:只允许调用 TOOL_FUNCTIONS 里的函数
if func_name not in TOOL_FUNCTIONS:
tool_result = f"错误:未知工具 {func_name}"
else:
try:
func = TOOL_FUNCTIONS[func_name]
tool_result = func(**func_args)
except Exception as e:
tool_result = f"错误:工具执行失败:{e}"
# 截断过长结果(避免 token 超限)
if len(tool_result) > 3000:
tool_result = tool_result[:3000] + f"\n...(截断,共 {len(tool_result)} 字符)"
print(f" ← 结果长度:{len(tool_result)} 字符")
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": tool_result,
})
return "[错误:超过最大轮次]"
if __name__ == "__main__":
# 测试 1:探索项目结构
print("=" * 60)
print("测试 1:探索项目")
print("=" * 60)
answer = run_agent("我的项目 llm-week01 里有哪些 Python 文件?它们大概是干什么的?")
print(f"\n助手回答:\n{answer}\n")
# 测试 2:搜日志里的 mAP
print("=" * 60)
print("测试 2:在日志里找 mAP")
print("=" * 60)
answer = run_agent("在 outputs/week02/ 下找日志文件,看最后一次跑的 mAP 多少")
print(f"\n助手回答:\n{answer}\n")