大模型week02

Day 1Pydantic 数据建模基础

Step 0:Pydantic 是什么

一、什么是 Pydantic

Pydantic 是 Python 非常流行的数据校验 + 数据解析库,利用 Python 类型提示 (type‑hint) 做校验。

核心口号:Data validation using Python type hints

FastAPI 的底层数据模型就是基于 Pydantic。

二、核心作用

  1. 数据校验:检查传入数据类型、范围、是否必填、格式(邮箱、日期),错了自动抛异常
  2. 类型转换(解析):尽量把输入值转成你声明的类型
  3. 序列化 / 反序列化:dict/json ↔ Python 对象互转
  4. 自动生成 JSON Schema(接口文档用)

⚠️ Pydantic 不是 ORM,不直接读写数据库

三、两个大版本区别(重点!)

  • Pydantic v1from 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 种解决方式(任选一种):

  1. 装 eval_type_backport(推荐,不改任何代码)
  2. 改用 Unionstr, int, float, bool 写法(不装包,但代码更啰嗦)
  3. 升级 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")
相关推荐
DBA_G1 小时前
GBase 8a数据库集群多维度资源管控策略矩阵解析
数据库·oracle
逐米时代2 小时前
智能招聘与人岗匹配:可解释匹配让录用依据有迹可循
大数据·数据库·人工智能
承渊政道2 小时前
KES专业技能包发布:覆盖数据库开发、迁移与运维全流程
运维·数据库·gitee·数据库开发·金仓数据库
IvorySQL3 小时前
PostgreSQL 日报| RI 快速路径并发读取缺陷(8 月 13 日)
数据库·postgresql
highreport3 小时前
HighReport报表工具定时调度和邮件定时发送
运维·服务器·数据库
01_ice3 小时前
数据库基础
数据库
deepdata_cn4 小时前
向量数据库是非结构化数据的AI检索底座
数据库·人工智能
梁辰兴4 小时前
软件工程:数据库设计
数据库·软件工程·数据库设计·逻辑设计·概念设计·梁辰兴·物理设计
10mAh4 小时前
【Git】误删分支、reset --hard 后提交丢了怎么办?——reflog、fsck 与安全恢复实战
数据库·git·安全·回归