← 返回学习路线 ◆ 贯穿项目
计算机与工程基础 · 阶段 1 · 编程与工程基础
Stage 01 / 17 · 计算机与工程基础

编程与工程基础 Engineering Foundations

这是整条路线唯一可以「快进但不可以跳过」的阶段。2026 年的 AI 工程师交付的不是一个 notebook,而是「模型 + 工具 + 数据 + 服务」的完整系统:训练脚本要能在集群上断点续跑,推理服务要能扛住并发,Agent 要能调工具、存状态、失败了能恢复,评测要能进 CI 当门禁。这些能力的地基全在这里。而且从这一阶段开始,你就要建立整个路线的载体——贯穿 17 个阶段的 Hamauls Orion 项目仓库(里程碑 M1)。

⏱ 4–6 周(有后端经验可压到 2 周) 🎯 入门 ◆ 里程碑 M1 2026-09-29
PythonasyncioPydanticpytestTypeScriptSSESQLpolarsDockerCI/CDuv

阶段总览

✔
学完你能做到
  • 写出类型清晰、有结构化日志、有单测、配置与代码分离的 Python 工程代码,而不是散装脚本
  • 掌握 asyncio / 线程 / 进程三种并发模型,能写出带限流、分类重试、断点续跑的高吞吐数据管线
  • 会用 Pydantic 在系统边界做校验,理解「LLM 输出是不可信输入」这条铁律
  • 能用 TypeScript 消费 SSE 流,实现流式渲染、停止生成、错误重试与消息状态机
  • 掌握 SQL 的窗口函数与 CTE,能独立完成数据清洗、去重、抽样与评测集构造
  • 熟练使用 Git / Linux / Shell / Docker,能在无 GUI 的服务器上定位问题
  • 会写多阶段 Dockerfile 与 CI 流水线,做到「一条命令跑起来」
  • 搭起 Hamauls Orion 仓库骨架:src 布局、uv 锁依赖、ruff/mypy/pytest 全绿、CI 生效
阶段知识结构总览 · 从语言到部署的完整链路
阶段 1 · 编程与工程基础Engineering Foundations · 4 大章 · 90+ 知识点
1. Python 工程能力类型标注 / Pydantic异常分层与重试asyncio 并发限流JSONL 断点续跑uv / Ruff / pytestcProfile / py-spy结构化日志
2. TypeScript 交付层SSE 流式传输Last-Event-ID 续传消息状态机AbortController 停止生成逐 token 渲染
3. 数据工程与 SQL窗口函数 / CTEJSON 字段解析pandas → polars数据质量四查去重与抽样
4. Git / Linux / Docker / CIGit 工作流Linux 排障与信号多阶段 DockerfileCI 流水线一条命令起服务
贯穿项目 · M1 仓库骨架与并发数据管线第 3–6 周src/hamauls_orion/{core,data,models,servin…uv.lock 已提交、ruff + mypy + pytest 在 CI 中全绿asyncio 并发调用模型 API,支持 Semaphore 限流、重试分类、JS…多阶段 Dockerfile,docker compose up 一条命令起服务
学习路径
周次主题交付物
第 1 周现代 Python:类型标注、dataclass / Pydantic、异常分层、结构化日志、调试一个带类型标注与结构化日志的 CLI 工具
第 2 周并发与异步:asyncio、Semaphore 限流、分类重试、批处理、断点续跑并发调用模型 API 并落 JSONL 的评测管线(M1 核心组件)
第 3 周工程化:uv / 项目布局 / pytest 三类测试 / 配置分层 / 可复现性Hamauls Orion 仓库骨架 + CI 全绿
第 4 周TypeScript 应用层 + 流式 UI + SQL 与数据处理可流式展示模型输出的最小前端 + 一份数据清洗脚本
第 5–6 周Git / Linux / Shell / Docker / 性能剖析(M1 收口)容器化 + 编排,一条命令起服务;一份剖析报告
✔
有 10 年 Java 背景的人怎么走:你已经具备 OO 设计、并发、JVM 调优、Maven / CI、SQL 的能力——这些直接迁移,不要重学。真正需要补的只有四件事:① Python 的异步与 GIL 心智模型(Java 的线程池直觉会误导你:Python 多线程因 GIL 无法并行计算,但 I/O 时 GIL 会释放);② pandas / polars 的向量化思维(告别 for 循环);③ Docker + Linux 命令行的熟练度;④ 从「强类型编译期安全」切换到「动态语言 + 类型标注 + 运行时校验(Pydantic)」的习惯。预计 2 周即可完成本阶段。

1. Python 工程能力

知识结构图 · Python 工程能力
Python 工程能力4 大知识域 · 27 个知识点
语言与可维护性类型标注 Type Hintdataclass / PydanticPydanticSettings 配置注入异常分层与重试分类指数退避 + 抖动结构化日志 JSONJSONL 断点续跑
学习路径
  1. 读 1.1:跟着代码把 Settings / dataclass / 异常分层抄一遍
  2. 跑内置代码,改为从环境变量注入 model_name 与 max_new_tokens
  3. 把 retry 分类补进异常处理(含退避)
  4. 对接 M1:用 Pydantic 校验评测管线入参并落 JSONL
✔ 能不查文档写出带 Pydantic 校验、异常分层与 JSONL 断点续跑的 CLI
核心知识点详解
  • 类型标注只是协议,不是约束:Python 的类型标注在运行时不生效,只有 linter / mypy 会校它。所以「可维护」= 类型标注 + 边界用 Pydantic 做运行时校验两件事都得做:标注保证协作方不传错形状,Pydantic 保证外部输入(尤其 LLM 输出、API 入参)在系统边界被拦住。
  • 配置要与代码分离(12-Factor):密钥 / 模型名 / 地址一律走环境变量注入,用 pydantic-settings 读进一个 Settings 对象;.env 绝不 commit,换环境只改配置不改代码。
  • 异常分类决定「重试 or 失败」:指数退避 base×(2^n)+jitter 用于 429 / 超时 / 连接错 / 5xx(上限约 30s、2–4 次);401/403/400 属于永久失败,重试只会浪费配额,应立刻失败并告警。
  • JSONL 断点续跑:每处理完一条就 append 一行到 .jsonl,启动时读入已完成 key 跳过,实现「断电重启不丢不重」,是大规模评测管线的标配。
并发与异步GIL 与 I/O 释放asyncio 事件循环Semaphore 限流asyncio.gather 批处理线程池 / 进程池取舍连续批处理 chunked prefill排队论定并发上限
学习路径
  1. 读 1.2:理解 GIL 只在 I/O 时释放,确定「并发数不是越大越好」
  2. 跑 Semaphore + asyncio.gather 的限流示例并观察耗时曲线
  3. 用排队论给并发上限定档(对比表里的 60–90% 现象)
  4. 完成 M1:并发管线支持限流 + 重试分类 + 统计 P95
✔ 能解释为什么 Python 多线程算不了并行、并写出带限流的并发调用,跑 1 万条不丢不重
核心知识点详解
  • GIL 让多线程「只能并发不能并行」:GIL 保证同一进程内同一时刻只有一个线程执行字节码,所以纯 Python 的 CPU 计算用多线程无法加速;但 I/O 等待、以及 numpy / torch 的 C 扩展会释放 GIL,因此网络调用、磁盘读写多线程有效。3.13+ 的 free-threaded 构建(PEP 703)正在解除限制,生产暂不宜依赖。
  • asyncio 的两个前提:① 全链路都要可 await(aiohttp / asyncpg / MCP async 客户端);② 没有 CPU 密集计算。满足才用 asyncio,单机可支撑上千并发连接,每个协程仅几 KB(比线程的 ~1MB 栈小得多)。
  • Semaphore 为什么必须有而不靠「随手并发」:不设并发上限会把对端限流打满被风控,或本地内存打爆。async with asyncio.Semaphore(N) 统一限流;N 用排队论估:目标 QPS × 平均耗时 = 所需并发,再留 2–3 倍余量应对 P99 长尾。
  • asyncio.gather 搭配 Semaphore 是标准批处理:gather(*(wrap(task) for task in tasks)) 配合信号量一次性提交一批,返回结果顺序与输入一致。CPU 密集的分词 / 特征计算要挪到 asyncio.to_thread / 进程池,否则会卡死事件循环。
工程化与测试uv 依赖与 uv.lockRuff / pre-commitsrc 布局与包命名pytest 三类测试覆盖率门槛配置分层 dev / prod随机种子与可复现
学习路径
  1. 读 1.3:照着搭 uv + src 布局 + pyproject 例子
  2. 提交 uv.lock,配置 ruff + mypy + pre-commit
  3. 为一条函数写单测,按表格里的「分层门槛」定覆盖率
  4. 对接 M1:让 hamauls-orion 骨架 CI 全绿(ruff+mypy+pytest)
✔ 能一条命令复现环境;ruff/mypy/pytest 在 CI 全绿;实验参数进配置而非代码
核心知识点详解
  • uv + uv.lock 是可复现的基石:uv.lock 固定每个依赖的精确版本与哈希,保证「今天能跑、下周还能跑」。必须提交进 git——不提交则不同机器 / 不同时间解析出不同版本,产生「薛定谔的可复现」。
  • src/ 布局是工程化的分水岭:源码放 src/ 会强制以「已安装的包」方式测试(pip install -e .),能提前暴露打包问题(漏包数据、packages 配置错、循环依赖),不因「当前目录正好被加入 sys.path」而侥幸通过。
  • pytest 三类测试各司其职:单元测试(单函数,快)→ 集成测试(跨模块,验证契约)→ 端到端(起服务,慢但真)。用 @pytest.mark 分 tag,CI 里分层跑,别让慢测试拖慢每次提交。
  • 可复现实验 = 参数进配置 + 记录三元组:随机种子、数据版本哈希、Prompt/模型配置都写进配置文件或记录,实验日志留下「数据 / Prompt / 模型」三元组哈希,任何一次实验都能凭记录还原。
调试与性能剖析cProfile / py-spy火焰图读法热点 80/20 法则tracemalloc 内存剖析先测量再优化别优化不重要的 5%
学习路径
  1. 读 1.4:先用 cProfile / py-spy 拿到火焰图再说
  2. 跑剖析示例,按 80/20 定位热点,避免优化不重要的 5%
  3. 用 tracemalloc 查一次内存峰值
  4. 对 M1 管线做一次剖析,记录各段耗时占比并砍掉最慢一段
✔ 能从火焰图读出瓶颈并在实测数据上给出至少一次已验证的提速
核心知识点详解
  • 先测量,再优化(Amdahl 定律):若某部分只占运行时间的 5%,再怎么优化整体最多快 5%。顺序:time.perf_counter 粗定位模块 → cProfile 找 Top5 自耗时函数 → line_profiler 定位到行 → 才动手。别优化不重要的 5%。
  • cProfile 看函数,py-spy 看运行中的进程:cProfile 改代码重跑看统计;py-spy 能附加到线上卡住的进程(py-spy dump --pid)dump 所有线程栈,是「卡住但没报错」的第一工具,strace -p 看系统调用进一步确认。
  • 火焰图读法:看宽块,不看顶层:火焰图上横向越宽 = 累计耗时越多,是真正的热点;竖着堆叠的是调用链。优先看最宽的总底部块,从它往下找优化点,而不是盯着最上面的叶子函数。
  • GPU 场景先看数据供给:GPU 利用率低(<30%)往往是数据加载瓶颈,不是「算得慢」。先用 nvidia-smi dmon 看利用率,再配 DataLoader num_workers>0 + pin_memory,常能拿到 2–5x 提升;内存峰值用 tracemalloc 定位。
学习路径

1.1 现代 Python:从能跑 → 可维护

AI 代码的最大反模式是「一个 3000 行 notebook 直接上生产」。2026 年面试官看简历时会直接问:你的训练/推理代码有类型标注吗?有单测吗?配置和密钥怎么管?这三个问题的答案,比你的模型指标更能说明工程素养。

pythonfrom dataclasses import dataclass
from typing import Protocol, Iterator
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings   # 运行时校验 + 环境变量注入

class Settings(BaseSettings):
    model_name: str = 'Qwen/Qwen3-8B'
    max_new_tokens: int = Field(512, ge=1, le=32768)
    temperature: float = Field(0.7, ge=0.0, le=2.0)
    api_key: str                        # 缺失时启动即报错,而不是跑到一半才炸
    class Config:
        env_file = '.env'
        env_prefix = 'HAMAULS_ORION_'

class LLM(Protocol):                    # 结构化子类型:只依赖接口,不依赖实现
    """为什么用 Protocol 而不是抽象基类?
       Python 是鸭子类型,Protocol 让你能对「任何长得像 LLM 的对象」编程,
       无需让第三方类继承你的基类 —— 这大幅降低了替换供应商的成本。
       在 Hamauls Orion 里,NullPushChannel / vLLM / 商业 API 都只需满足这个接口。"""
    def chat(self, messages: list[dict], **kw) -> str: ...

@dataclass(frozen=True, slots=True)
class Sample:                           # 不可变 + 省内存,评测集常用
    prompt: str
    reference: str
    meta: dict

def chunked(it: Iterator[str], n: int) -> Iterator[list[str]]:
    """惰性分批:处理 1 亿条语料时,绝不能先 list() 再切"""
    buf: list[str] = []
    for x in it:
        buf.append(x)
        if len(buf) >= n:
            yield buf; buf = []
    if buf: yield buf
python# 可重试 vs 不可重试 —— 这是所有 AI 服务的通用骨架
import asyncio, random, logging
from typing import Awaitable, Callable, TypeVar

log = logging.getLogger(__name__)
T = TypeVar('T')

class RateLimitError(Exception): ...
class AuthError(Exception): ...

RETRYABLE = (asyncio.TimeoutError, ConnectionError, RateLimitError)
FATAL     = (AuthError, ValueError)          # 明确列出,避免 except Exception 一把抓

async def with_retry(fn: Callable[[], Awaitable[T]], tries: int = 4,
                     base: float = 0.8, cap: float = 30.0) -> T:
    for i in range(tries):
        try:
            return await fn()
        except FATAL:
            raise                            # 不可重试:立刻抛,不浪费配额
        except RETRYABLE as e:
            if i == tries - 1:
                raise
            # 指数退避 + 抖动(jitter):避免大量客户端同步重试造成惊群
            delay = min(cap, base * (2 ** i)) * (0.5 + random.random() * 0.5)
            log.warning('retry %d/%d after %.2fs: %s', i + 1, tries, delay, e)
            await asyncio.sleep(delay)

# 注意这里没有 except Exception —— 让未知异常直接冒泡。
# 「宽泛捕获」会掩盖真正的 bug,是最常见的技术债来源。
python# Pydantic v2 在「系统边界」拦截脏数据 —— LLM 输出 = 不可信输入
from pydantic import BaseModel, Field, field_validator
from typing import Literal

class Answer(BaseModel):
    answer: str = Field(min_length=1, max_length=8000)
    cites: list[int] = Field(default_factory=list, max_length=20)
    route: Literal['direct', 'rag', 'tool'] = 'direct'

    @field_validator('cites')
    @classmethod
    def normalize_cites(cls, v):
        return sorted(set(v))          # 归一化:去重 + 排序,下游不必再防御

obj = Answer.model_validate_json('{"answer": "RoPE 用旋转编码相对位置", "cites": [3, 1, 3]}')
print(obj.cites, obj.route)
# 预期输出: [1, 3] direct        ← 3 被去重、顺序被规范化

try:
    Answer.model_validate_json('{"answer": ""}')
except Exception as e:
    print(type(e).__name__)
# 预期输出: ValidationError       (min_length=1 拦截空答案,不会静默通过)
异常类别典型来源是否重试退避策略失败后动作
限流 429对端 QPS 上限、并发打满是指数退避 + 全抖动降并发并记录,不放大请求
超时 Timeout网络抖动、对端排队、长上下文是(次数少)固定 + 抖动,2–3 次超阈值则截断上下文再试
连接错误 / 5xx网关、服务重启是指数退避,上限 30s熔断该后端,切备用
401 / 403 鉴权密钥过期、权限不足否—立即失败并告警(重试纯浪费配额)
400 参数非法 / schema 不符代码 bug、模型输出越界否—降级解析或转人工,修代码
✔
2026 工程实践:uv + Ruff 把「反馈回路」压到秒级:2026 年新项目的默认组合已是 uv(包管理 / 锁依赖 / Python 版本管理三合一)与 Ruff(lint + format 二合一,Rust 实现)。量级对比:uv sync 冷启动解析依赖通常在 100–300ms 量级,pip install 常是 10–60s;Ruff 跑完一个中等仓库的 lint 约 几十毫秒,比 flake8 + isort + black 的组合快一到两个数量级。把 lint / 类型检查 / 单测放进 pre-commit 后,本地提交即可拦住大部分问题,CI 只兜底。反馈回路越短,工程习惯越容易坚持。

1.2 并发与异步:AI 代码的性能命门

LLM 调用是典型的 I/O 密集任务:99% 的时间在等网络。用同步 for 循环调 1000 条数据,耗时是并发的几十倍。这不是「优化技巧」,而是「能不能在今晚跑完评测集」的问题。

模型适用场景注意与 Java 经验的对应
asyncio 协程模型 API 调用、数据库、HTTP、MCP 工具调用必须全链路 async;混用同步阻塞调用会退化类似 Netty / 响应式:单线程事件循环 + 回调
线程池调用只有同步接口的库(部分 SDK、文件 I/O)受 GIL 限制,计算密集无收益像线程池,但 GIL 让 CPU 密集无效
多进程CPU 密集:数据预处理、tokenize、特征计算进程间通信有成本,别传大对象像多进程 ForkJoin
向量化(numpy/polars)批数据处理,优先级最高先用向量化替代循环,再考虑并发类似 Stream API,但收益大得多
pythonimport asyncio, aiohttp, json

async def eval_one(session, sem, item):
    async with sem:                             # 限流:保护对端,也保护自己不被封
        async with session.post(URL, json=item) as r:
            return await r.json()

async def run(items, concurrency=16):
    sem = asyncio.Semaphore(concurrency)
    async with aiohttp.ClientSession() as s:
        tasks = [eval_one(s, sem, it) for it in items]
        return await asyncio.gather(*tasks, return_exceptions=True)

# 大评测集要边跑边落盘,避免中途失败全部重来 —— Hamauls Orion M1 的核心组件
async def run_streaming(items, out_path, concurrency=16):
    sem = asyncio.Semaphore(concurrency)
    done = 0
    async with aiohttp.ClientSession() as s, open(out_path, 'a') as f:
        for coro in asyncio.as_completed([eval_one(s, sem, it) for it in items]):
            try:
                f.write(json.dumps(await coro, ensure_ascii=False) + '\n')
                f.flush()                       # 断点续跑的基础(OS 缓冲会吞数据)
            except Exception as e:
                # 单条失败不能中断整个流水线 —— 记录并继续
                f.write(json.dumps({'error': str(e)}) + '\n'); f.flush()
            done += 1
            if done % 100 == 0:
                print(f'{done}/{len(items)}')

# 断点续跑:启动时读取已完成的行,跳过已处理的(这是工程上必须的能力)
async def resume(items, out_path, **kw):
    import os
    if os.path.exists(out_path):
        with open(out_path) as f:
            done = sum(1 for _ in f)
        items = items[done:]
        print(f'从第 {done} 条续跑,剩余 {len(items)} 条')
    await run_streaming(items, out_path, **kw)
⚠
三个必踩的坑:① asyncio.gather 默认遇到第一个异常不会取消其他任务——要配 return_exceptions=True 或(Python 3.11+)用 TaskGroup 获得正确的取消语义;
② 在 async 函数里调用同步阻塞函数(如 requests.get、time.sleep、某些 SDK)会阻塞整个事件循环,让所有并发全部退化——必须用 asyncio.to_thread() 包一层;
③ 无限并发会打爆对端限流,务必用 Semaphore;但并发数也不是越大越好——超过对端承载能力后,成功吞吐会下降(重试增多),必须实测找最优点。
python# 并发数不是越大越好:实测「并发 — 吞吐」曲线,找拐点
import asyncio, time, random

async def one(sem, lat=0.10):
    async with sem:
        await asyncio.sleep(lat * (0.8 + 0.4 * random.random()))
        return 1

async def bench(n_items=200, concurrency=16, lat=0.10):
    sem = asyncio.Semaphore(concurrency)
    t0 = time.perf_counter()
    await asyncio.gather(*[one(sem, lat) for _ in range(n_items)])
    dt = time.perf_counter() - t0
    return n_items / dt                    # QPS

async def main():
    for c in (1, 8, 32, 128, 512):
        print(f'concurrency={c:3d}  qps={await bench(200, c):8.1f}')

asyncio.run(main())
# 预期输出(单条纯等待约 0.1s):
# concurrency=  1  qps=    10.0
# concurrency=  8  qps=    79.5
# concurrency= 32  qps=   315.2
# concurrency=128  qps=  1260.9
# concurrency=512  qps=  1800.0   ← 事件循环与调度开销开始压住收益,趋于饱和

用排队论定并发上限:Little 定律 L = λ · W(系统内平均在途数 = 到达率 × 平均逗留时间)。若单条请求平均耗时 W 秒、目标吞吐 λ QPS,则稳态所需并发约 L = λ · W。例如 W=2s、想跑 50 QPS,需要约 100 并发;因为长尾(P99 常是 P50 的 5–20 倍),实际要留 2–3 倍余量。而吞吐随并发增长会饱和:并发超过对端承载后排队使 W 上升,λ 反而下降(重试放大)。这就是「并发调大反而变慢」的机理。

并发数200 请求总耗时吞吐 QPS现象
1约 20.0s约 10串行,完全没用上并发
8约 2.5s约 80接近线性加速
32约 0.63s约 320仍近线性
128约 0.16s约 1260开始受调度 / 内存开销影响
512约 0.11s约 1800饱和:再加大收益递减甚至倒挂
★
2026 案例:连续批处理把「并发」做进推理引擎:2026 年服务自托管模型,吞吐的关键已不在客户端并发数,而在引擎的调度:vLLM 的 PagedAttention 把 KV Cache 按页管理,几乎消除显存碎片,相同延迟预算下吞吐可达朴素 HF 实现的 2–4 倍;SGLang 的 RadixAttention 用前缀树复用共享前缀,多轮对话 / RAG(同一 system prompt 或同一文档)场景前缀缓存命中率常达 60–90%,TTFT 可降数倍;chunked prefill 把长 prefill 切片与 decode 交错,缓解「一个长请求拖慢所有解码」。这些正是第 16 阶段服务化要读懂的对象——而客户端侧仍必须限流,否则你只是在把压力原样转发。

1.3 工程化:包管理、项目结构、测试与可复现

textHamauls Orion 的仓库布局(这一层的设计会一直用到第 17 阶段)

hamauls-orion/
├── pyproject.toml          # 项目元数据 + 依赖(uv 管理)
├── uv.lock                 # 锁文件,必须提交到 git
├── README.md
├── Makefile                # make dev / make test / make build 统一入口
├── configs/                # 配置(hydra 风格),不入 git 的只有 secrets
│   ├── base.yaml
│   ├── train/sft.yaml
│   └── serving/vllm.yaml
├── src/hamauls_orion/              # 源码放在 src/ 下(避免「当前目录恰好能 import」的假象)
│   ├── core/               # 配置、日志、异常、通用类型
│   ├── data/               # 数据加载、清洗、版本管理
│   ├── models/             # 模型封装(Protocol 定义在这里)
│   ├── index/              # 检索索引(第 3 阶段的产出落这里)
│   ├── rag/                # 检索增强管线(第 13 阶段)
│   ├── agent/              # Agent 与工具(第 14 阶段)
│   ├── serving/            # 网关、超时、重试、限流(第 4、16 阶段)
│   └── evals/              # 评测与门禁(第 15 阶段)
├── tests/
│   ├── unit/               # 纯函数单测,快
│   ├── integration/        # 需要外部依赖,标记为 slow
│   └── snapshots/          # prompt / schema 快照
├── scripts/                # 一次性脚本与入口
├── notebooks/              # 探索性分析,不允许被 src/ import
└── docker/
    ├── Dockerfile
    └── docker-compose.yaml
★
两种写法不是笔误:仓库名用连字符,包名用下划线:上图里 hamauls-orion/ 与 src/hamauls_orion/ 必须长得不一样。仓库名 / 发行名用连字符(PEP 508 的约定;PyPI 做归一化时会把 _ 与 - 视为同一个包);import 名与包目录只能用下划线——因为 - 在 Python 里是减号运算符,import hamauls-orion 会直接 SyntaxError,PEP 8 也要求包名能当标识符。落到 pyproject.toml:name = "hamauls-orion",[project.scripts] 下写 hamauls-orion = "hamauls_orion.cli:main"(等号左边是命令行工具名,可带连字符;右边是模块路径,只能下划线)。同样地,journalctl -u hamauls-orion 里的 systemd unit 名用连字符,而 ps aux | grep hamauls_orion 匹配到的是进程命令行里的模块路径——两者各指真实对象,都不是写错。
python# tests/unit/test_pipeline.py —— AI 项目里最该写的三类测试
import pytest
from pydantic import ValidationError

def test_schema_validation():
    """LLM 输出必须能被 schema 校验,脏数据要降级而不是崩"""
    assert parse_or_default('{"a": 1}', schema=MySchema) is not None
    assert parse_or_default('抱歉我无法回答', schema=MySchema) is None   # 降级路径

def test_prompt_snapshot():
    """Prompt 变更必须被看见:快照测试防止顺手改坏"""
    assert build_prompt(SAMPLE_INPUT) == open('tests/snapshots/p1.txt').read()

def test_gradable_llm_output():
    """不比对全文(模型输出必然变化),而是断言结构与关键约束"""
    out = call_model('用 JSON 输出用户姓名和年龄')
    parsed = json.loads(out)                     # 结构必须可解析
    assert set(parsed) >= {'name', 'age'}
    assert isinstance(parsed['age'], int)

@pytest.mark.slow
def test_e2e_smoke():
    """跑通一条最短链路,验证接线正确(用最小模型 / mock)"""
    assert run_pipeline('你好')['answer']
✔
一条能省几百小时的规矩:任何一次「模型效果变差了」的排查,第一问永远是数据 / 配置 / 版本变了没有,第二问才是代码。把数据版本、Prompt 版本、模型版本三者的哈希记进每次实验的日志里,你就拥有了回溯能力。没有这个习惯,你会在「到底是哪次改动导致的」上浪费几个星期。
toml# pyproject.toml —— 现代 Python 项目的最小可信配置(uv / ruff / mypy / pytest 一处集中)
[project]
name = "hamauls-orion"                  # 发行名可用连字符
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["pydantic>=2.7", "httpx>=0.27", "polars>=1.0", "structlog>=24.1"]

[project.scripts]
hamauls-orion = "hamauls_orion.cli:main"   # 左侧命令名可连字符,右侧模块名下划线

[dependency-groups]
dev = ["pytest>=8.0", "pytest-cov>=5.0", "ruff>=0.6", "mypy>=1.10", "pre-commit>=3.7"]

[tool.ruff]
line-length = 100
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM"]     # I=isort, B=bugbear, UP=pyupgrade

[tool.mypy]
strict = true
warn_unreachable = true

[tool.pytest.ini_options]
markers = ["slow: 需要外部依赖的端到端测试"]
addopts = "-q -m 'not slow'"
工具2026 定位关键数字 / 取舍
uv依赖解析 + 锁文件 + Python 版本管理三合一(Rust)冷解析常在 100–300ms;比 pip 快 10–100x;uv.lock 必须提交
pip + requirements.txt最普及但不可复现(无锁、无哈希)只在极简环境或历史项目里用
poetry较成熟,锁文件 poetry.lock功能全但解析慢、workspace 支持弱
conda / mamba需要非 Python 二进制依赖(CUDA、GDAL)时仍常用体积大、环境重,纯 Python 项目一般不选
ℹ
覆盖率门槛怎么定才合理:AI 项目里「覆盖率」不能被简单当 KPI:训练脚本、GPU 分支很难在 CI 里跑到。务实做法是「分层门槛」:解析 / 校验 / 数据清洗这类纯函数要求 ≥85%;服务层 ≥70%;训练与 GPU 代码只要求「有 smoke test」。CI 用 --cov-fail-under=70 兜底,但要接受它衡量的是「接线正确」而不是「行为完备」。比覆盖率数字更重要的,是关键的失败路径(重试、降级、schema 不符)有没有测试。

1.4 调试与性能剖析:先测量再优化

这一节与第 2 阶段的性能分析呼应,但聚焦在Python 层的定位手段。原则只有一条:不要猜,要测。

bash# ① 找到热点函数(cProfile:函数级调用次数与自耗时)
python -m cProfile -o prof.out -s cumtime scripts/run_eval.py
python -c "import pstats; pstats.Stats('prof.out').sort_stats('cumtime').print_stats(20)"

# ② 附加到正在运行的进程(生产环境排查,不重启)
py-spy dump --pid <PID>              # 立刻打印所有线程的调用栈 ← 排查「卡住了」
py-spy top --pid <PID>               # 实时看哪个函数在烧 CPU
py-spy record -o profile.svg --pid <PID>   # 生成火焰图

# ③ 逐行耗时(找到热点函数后用)
kernprof -l -v scripts/hot_function.py     # line_profiler

# ④ 内存泄漏排查
tracemalloc / objgraph / memray run script.py
memray flamegraph memray.bin               # 内存火焰图,定位增长点

# ⑤ 最朴素但最有效的一招:手工计时(粗粒度先定位到模块)
python -c "
import time
t = {}
def tick(k):
    now = time.perf_counter()
    if k in t: print(f'{k}: {now - t[k]:.3f}s')
    t[k] = now
tick('start'); load_data(); tick('load')
retrieve(); tick('retrieve'); generate(); tick('generate')
"
python# 两个立竿见影的微优化(在内层循环里差异巨大)
import logging, time
log = logging.getLogger(__name__)

# ❌ f-string 即使日志被过滤掉也已经付出了格式化成本
for x in big_list:
    log.debug(f'processing {x}')          # 慢

# ✅ 延迟格式化:只有真的要输出时才格式化
for x in big_list:
    log.debug('processing %s', x)         # 快

# ❌ 字符串累加 O(n²)
out = ''
for tok in tokens: out += tok             # 每次都在重新分配

# ✅ join 一次性分配
out = ''.join(tokens)

# 实测对比(10 万个短字符串):
#   += 累加: 约 0.35s
#   join   : 约 0.002s      → 快 100 倍以上
python# 用 timeit 把「优化前后」量化,避免凭感觉
import timeit, random

tokens = [str(random.random()) for _ in range(100_000)]

def bad():
    s = ''
    for x in tokens:
        s += x
    return s

t_join = timeit.timeit(lambda: ''.join(tokens), number=100) / 100
t_add  = timeit.timeit(bad, number=20) / 20
print(f'join={t_join*1000:.2f}ms  plus={t_add*1000:.2f}ms  speedup={t_add/t_join:.0f}x')
# 预期输出(10 万字符串,单次):
# join=1.30ms  plus=210.00ms  speedup=160x   ← 数量级差距,不是「差不多」
热点典型占比改法收益量级
Python 层循环处理大数据40–80%换 numpy / polars 向量化10–100x
字符串累加 / 频繁格式化5–30%join / %-延迟格式化10–100x
重复的 tokenize / 编码20–50%批量 + 缓存 + 多进程2–8x
同步 I/O 阻塞事件循环可致全站卡顿to_thread / 换异步驱动数量级
GPU 空闲等数据GPU 利用率常 <30%DataLoader num_workers>0 + pin_memory2–5x
⚠
失败模式:优化了不重要的 5%:Amdahl 定律:若某部分只占运行时间的 5%,再怎么优化它,整体最多也只能快 5%。所以「先测再改」不是口号——你花一周优化一个 3% 的模块,收益趋近于 0。正确顺序是:先用 time.perf_counter 粗定位到模块 → cProfile 找 Top 5 自耗时函数 → line_profiler 定位到行 → 才动手。GPU 场景还要配合 nvidia-smi dmon 看利用率,利用率低通常是数据供给瓶颈,而不是算得快。

1.5 动手练习与自测(Python 工程)

★
练什么 / 怎么算过:以下 6 题,做完能确认你把「可维护 + 可并发 + 可复现」三件事落到了手上。每题给出判据 / 参考答案要点,先自己做,再对照。
  1. 在本机用 timeit 对比 += 与 join 处理 10 万个短字符串,报出倍数并解释复杂度差异。判据:倍数通常 ≥100x;+= 每次重新分配整串,是 O(n²),join 先算总长再一次性分配,是 O(n)。
  2. 用 Little 定律估算所需并发:若单条请求平均耗时 W=1.5s、目标吞吐 λ=40 QPS,需要多少并发?为什么还要再留余量?参考答案:约 40 × 1.5 = 60 并发;因 P99 长尾常是 P50 的 5–20 倍,实际需 2–3 倍余量,否则尾延迟爆炸。
  3. 给一段在 async 函数里直接调用 requests.get 与 time.sleep 的代码,指出问题与两种修法。判据:两者都会阻塞事件循环,使全部并发退化为串行;修法一:await asyncio.to_thread(...);修法二:换成 httpx.AsyncClient / asyncio.sleep。
  4. 把 6 种典型异常分成「必须重试 / 绝不重试」两类,并说明退避策略。参考答案:重试=429 限流、超时、连接错误 / 5xx(指数退避 + 抖动,上限 30s,次数 2–4);不重试=401/403 鉴权、400 参数非法 / schema 不符(重试只浪费配额,应立刻失败并告警)。
  5. 为什么 uv.lock 必须提交进 git?不提交会发生什么?判据:锁文件固定了每个依赖的精确版本与哈希,保证「今天能跑、下周还能跑」;不提交则不同机器 / 不同时间解析到不同版本,产生「薛定谔的可复现」。
  6. 线上 Python 服务「卡住但没报错」,写出三步定位命令。参考答案:① py-spy dump --pid 看所有线程调用栈(最快判断卡在哪一行);② ps aux | grep 确认进程与 CPU;③ strace -p -e trace=network 看系统调用。若栈停在同步 I/O 或锁上,基本即锁定原因。
自测项合格线优秀线
类型标注与 Pydantic 边界校验能在 CLI 项目里全量标注并通过 mypy对所有外部输入(含 LLM 输出)都有校验与降级
并发管线asyncio + Semaphore 跑通 1 万条不丢不重含分类重试、断点续跑、P50/P95 延迟与 token 成本统计
可复现性uv.lock + 数据版本哈希进仓库实验日志三元组(数据/Prompt/模型哈希)全记录

2. TypeScript:AI 应用的交付层

知识结构图 · TypeScript 交付层
TypeScript:AI 应用的交付层3 大知识域 · 15 个知识点
流式传输协议SSE vs WebSocket vs 长轮询EventSource / ReadableStreamLast-Event-ID 断线续传HTTP/2 多路复用心跳保活
学习路径
  1. 读 2.1:弄清 SSE 与 WebSocket 的分工,别用一个全能的预期做错选型
  2. 跑内置 fetch + ReadableStream 的 SSE 消费骨架,打印增量 delta
  3. 实现 Last-Event-ID 断线续传;用 AbortController 触发一次停止生成
  4. 完成动手练习:实现一个最小流式聊天框(+状态机)
✔ 能讲清何时选 SSE 而非 WebSocket,并写出带断线续传与停止的流式前端
核心知识点详解
  • SSE vs WebSocket vs 长轮询:AI 流式输出默认用 SSE:单向服务端推送、天然走 HTTP、可 Last-Event-ID 断线续传、中间无代理障碍。WebSocket 是双向全双工,适合需服务端主动下推和客户端互发(协作、多 Agent);长轮询仅作兼容老环境兜底。
  • EventSource 却常配 fetch + ReadableStream:原生 EventSource 自动重连但不能自定义请求头(无法带鉴权)。实际更多用 fetch + response.body.getReader() 逐段读流,拿到可控性与鉴权能力。
  • AbortController 让「停止生成」真正生效:流式中止调用方与后端必须联动:前端 controller.abort() 断开连接,后端要监听 asyncio.CancelledError / 断开信号去取消模型生成,否则模型仍在偷偷烧 token。
前端状态与渲染消息状态机 pending / streaming / done / error逐 token 渲染与节流AbortController 停止生成错误重试与降级Markdown 增量渲染
学习路径
  1. 读 2.1 的状态机部分:理解每个消息必须在 pending/streaming/done/error 之一
  2. 跑骨架,把增量文本渲染进 UI,用节流避免每 token 一次重渲染
  3. 写错误重试:区别「可重试的 5xx」与「不可重试的 4xx」,做降级
  4. 对接 M1:为 M1 前端消费流式输出加上错误与停止态
✔ 能画清消息状态机,并实现逐 token 渲染 + 停止 + 错误降级
核心知识点详解
  • 每条消息都在一个显式状态机里:pending → streaming → done / error。别用布尔 flag 到处拼——状态机让「正在生成时又来一条」「停止后残留半句」这类竞态自然消失,也便于在 done 后禁用输入、在 error 后展示降级。
  • 逐 token 渲染要节流:每 token 渲染一次会触发大量 DOM 重排。用 requestAnimationFrame / 文本缓冲区把 30–60fps 的量合并提交,显示体验几乎不变但 CPU 大幅下降。
  • 错误重试要区分可重试与不可重试:5xx / 网络断 → 可重试(指数退避);4xx(403 鉴权、400 非法参数)→ 不可重试,直接降级并提示用户。别把「重试」和无差别告警混为一谈。
  • Markdown 增量渲染避免整块重渲:流式输出用增量 Markdown(如 mdast 增量 patch 或简易分块)逐块上屏,而不是每次全量 marked() 整段重渲,否则长文档会明显卡顿。
交付与联调前后端类型契约对齐Vite 构建CORS 与鉴权头前端埋点可观测
学习路径
  1. 读 2.1 联调部分:前后端共享一份类型(响应 schema),别靠嘴对齐
  2. 配 Vite 构建,处理 CORS 与鉴权头的一个实际报错
  3. 加一行前端埋点,指标里带上 token 消耗与耗时
  4. 对接 M1:让前端能连上后端 SSE 网关完成端到端流式
✔ 能独立把一个 TS 前端 build 起来、连上后端、并在错误时给出可观测日志
核心知识点详解
  • 前后端共享一份类型,别靠嘴对齐:把响应 / 请求 schema 抽成双方共同依赖的 .ts(或从 OpenAPI 生成),字段名、嵌套结构、可选性一处改多处同步,杜绝「后端改了前端还说 404」。
  • CORS 与鉴权头是联调常见坑:跨域用 Access-Control-Allow-Origin(生产别用 * + 带凭据的组合);OPTIONS 预检要正确处理。鉴权头(Authorization / Cookie)在 fetch 里要显式带 credentials 并按同源策略放行。
  • 前端埋点 = 可观测闭环的最后一环:每个请求带上 token 消耗、首 token 延迟、完整耗时,落到指标里。没有埋点的前端,排查线上 badcase 只能靠「重现场景」猜,效率极低。
学习路径
传输协议→状态与渲染→联调上线→→ M1 交付

2.1 为什么 AI 工程师也要会一点前端

2026 年的 AI 产品绝大多数以「对话 + 流式输出 + 工具调用可视化」的形态交付。而流式、SSE、断线重连、消息状态机、把模型的 tool_call 渲染成可交互卡片——这些都在前端。会用 TypeScript 意味着你能独立把 demo 变成产品,而不是卡在「等前端排期」。

typescript// 消费 SSE 流:AI 应用最小骨架(前端)
type Chunk = { delta?: string; tool_call?: unknown; usage?: unknown };

async function* streamChat(messages: Message[], signal?: AbortSignal) {
  const res = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ messages }),
    signal,                                   // 支持「停止生成」
  });
  if (!res.ok || !res.body) throw new Error(await res.text());

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buf = '';
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });
    const parts = buf.split('\n\n');        // SSE 以空行分帧
    buf = parts.pop() ?? '';
    for (const part of parts) {
      if (part.startsWith(':')) continue;     // 注释行 = 心跳,跳过
      const line = part.replace(/^data:\s*/, '');
      if (line === '[DONE]') return;
      yield JSON.parse(line) as Chunk;        // {delta} | {tool_call} | {usage}
    }
  }
}

// 消费端:把一个「流」变成「可取消 + 可重试 + 状态明确」的 UI
async function send(text: string, setState: (s: MsgState) => void, ctrl: AbortController) {
  setState({ phase: 'loading', text: '' });
  try {
    for await (const c of streamChat(history, ctrl.signal)) {
      setState(prev => ({ phase: 'streaming', text: prev.text + (c.delta ?? '') }));
    }
    setState(prev => ({ ...prev, phase: 'done' }));
  } catch (e) {
    if ((e as Error).name === 'AbortError') setState(prev => ({ ...prev, phase: 'stopped' }));
    else setState(prev => ({ ...prev, phase: 'error', error: String(e) }));
  }
}
typescript// 生产级 SSE 消费:断线重连 + 续传 + 指数退避(对应 2026 的 AI 流协议)
async function* connect(url: string, signal: AbortSignal) {
  let lastId: string | null = null;
  let attempt = 0;
  while (!signal.aborted) {
    const headers: Record<string, string> = { Accept: 'text/event-stream' };
    if (lastId) headers['Last-Event-ID'] = lastId;   // 让服务端从断点续发
    try {
      const res = await fetch(url, { headers, signal });
      if (!res.ok || !res.body) throw new Error(String(res.status));
      attempt = 0;                                    // 连上就重置退避
      for await (const evt of parseSSE(res.body)) {
        if (evt.id) lastId = evt.id;
        yield evt;
      }
    } catch (e) {
      if (signal.aborted) return;
      attempt += 1;
      const wait = Math.min(30_000, 2 ** attempt * 500) * (0.5 + Math.random() * 0.5);
      await new Promise(r => setTimeout(r, wait));    // 指数退避 + 抖动
    }
  }
}
// 关键点:EventSource 只能 GET、无法自定义 header / body,
// 生产里普遍用 fetch + ReadableStream 手写,而不是原生 EventSource。
// 若中途 reader 未读满就切换,务必要 reader.cancel() 释放连接。
传输方向重连适合场景2026 取舍
SSE服务器→客户端单向协议内置,可 Last-Event-ID 续传LLM token 流、进度、日志主流:走 HTTP/2、过代理友好
WebSocket双向需自己实现心跳与重连实时协作、语音、多轮双向协议更重,纯文本流多数不必
长轮询拉取模拟天然重连老环境 / 受限网络兜底延迟高、连接开销大,仅兜底
★
2026 案例:Vercel AI SDK 的 UI Message Stream 已成事实协议:2026 年前端做 AI 聊天,普遍不再自己定 SSE 数据格式,而是用 AI SDK 的 UI Message Stream 协议(在 SSE 之上定义了 text-delta / reasoning-delta / tool-input-available / tool-output-available / finish / error 等事件类型),配合 React 的 useChat 直接拿到「消息状态机 + 工具调用卡片 + 中断重试」。手写一次原始 SSE 消费仍然必要——线上出现「消息卡住」「重复 token」「工具结果丢失」时,你要能顺着协议逐帧核对,而不是只能重装依赖。GPT-5 / Gemini 3 / Claude 4.5 的流式工具调用都走了「先发 tool_call 增量、再发参数增量、最后发结果」的分阶段事件。

2.2 动手练习与自测(TypeScript 交付层)

★
练什么 / 怎么算过:5 题,做完能独立完成一个「断线能续、可中断、能渲染工具调用」的流式前端。
  1. 写一个最小 SSE 消费循环,遇到 \n\n 分帧、跳过注释行心跳、识别 [DONE]。判据:缓冲未闭合的尾帧、注释行以 : 开头跳过、data: 前缀剥离后再 JSON.parse。
  2. 为什么生产里多用 fetch + ReadableStream 而不是原生 EventSource?参考答案:EventSource 只能发起 GET、不能带 Authorization 等自定义 header、不能发 POST body、错误重连能力有限;LLM 对话要 POST 且要鉴权,因此手写。
  3. 实现「停止生成」,并说明服务端要做什么。判据:前端用 AbortController 把 signal 传给 fetch;服务端需在连接断开 / 取消时终止推理(否则继续占用 GPU 生成无人消费的 token)。
  4. SSE 的断点续传需要客户端与服务端各做什么?判据:服务端每条事件带 id:;客户端重连时带 Last-Event-ID 头;服务端据此从该 id 之后续发,避免重复与丢失。
  5. 把模型输出渲染进页面有哪些安全与性能坑?判据:安全——XSS,禁止 innerHTML 直插,用安全 markdown 渲染(如 DOMPurify 或多层白名单);性能——避免每个 token 触发全量重渲染(用增量 markdown 解析 + 虚拟列表)。
自测项合格线优秀线
SSE 消费能正确按空行分帧、跳过心跳、识别 [DONE]含 Last-Event-ID 续传与指数退避重连
交互状态机loading / streaming / done / error 四态清晰再加 stopped 与可重试,且服务端能响应取消
渲染安全与性能模型输出走安全 markdown 渲染增量解析 + 虚拟列表,长对话不卡

3. 数据工程与 SQL:被低估的胜负手

知识结构图 · 数据工程与 SQL
数据工程与 SQL2 大知识域 · 18 个知识点
SQL 进阶窗口函数与 OVER窗口帧 ROWS / RANGECTE 与递归 CTEJOIN 语义与去重JSON 字段解析分组去重与分层抽样
学习路径
  1. 读 3.1:先会写窗口函数(ROW_NUMBER / RANK / LAG)与 OVER 帧
  2. 跑内置 SQL,用 CTE 拆一个「先过滤再汇总」的查询
  3. 重点处理 JOIN 产生的重复:去重前先想清楚按哪个维度(主键/时间窗)
  4. 对接 M1/M2:用窗口函数从调用日志里构造评测集(分层抽样)
✔ 能独立写出带窗口函数 + CTE + 分层抽样的数据清洗脚本
核心知识点详解
  • 窗口函数 = 分组运算但保留每一行:ROW_NUMBER() OVER (PARTITION BY … ORDER BY …) 给组内排序号;RANK / LAG / SUM() OVER 在不压扁行数的前提下做组内计算。是「去重取最新一条」「算滑动平均」的标配。注意 ROWS / RANGE 帧决定窗口边界。
  • CTE 把复杂查询拆成可读步骤:WITH 步骤1 AS (…), 步骤2 AS (…) SELECT … 让「先过滤 → 再汇总」这类多步逻辑逐段可读、可测。递归 CTE 用于树形结构(组织层级、对话树)。
  • JOIN 产生重复要先想清维度:多表 JOIN 若一端有重复行会放大结果成笛卡尔积。去重前先确定按哪个维度(主键 / 时间窗 / 意图组),用窗口函数按正确维度取第一条,避免计数翻倍。
  • 分层抽样保证评测集分布不偏:纯随机抽样会丢失稀有意图类别的代表。STRATIFY / 按意图分区分别采样,保证每个意图在 train/test 里都有代表且比例稳定,评测结果才可信。
数据处理与质量pandas → polars 向量化LazyFrame 惰性求值数据质量四查(完整性 / 一致性 / 唯一性 / 时效性)多层去重策略评测集构造与 train/test 隔离内存与吞吐取舍
学习路径
  1. 读 3.2:把一段 pandas for 循环改成 polars LazyFrame(向量化)
  2. 跑内置代码,对比 pandas 与 polars 在大表上的内存与耗时
  3. 按「数据质量四查」逐项核对一份脏数据,记录每类抓到的量
  4. 对接 M6/M13:构造冻结评测集,做 train/test 隔离并写进 docs
✔ 能把脏数据清洗成可复现的训练/评测集,并讲清每步去重抓的是什么
核心知识点详解
  • pandas → polars:告别 for 循环:polars 的 LazyFrame 先构建查询图再惰性执行,多核并行 + 内存委派,大表上比 pandas 快一个数量级,且不把中间结果全塞进内存。AI 数据管线(清洗 / 构造 / 评测集)默认首选用 polars。
  • 数据质量四查:完整性(有没有空值/缺失)、一致性(类型/单位/编码是否统一)、唯一性(是否重复、按哪个键)、时效性(是否过期、滞后)。逐项核对并记录每类抓到多少,是「把数据搞干净」的操作定义。
  • 多层去重不是「去一次」:精确去重(完全相同的行)→ 模糊去重 / 近重复(改写文本)→ 意图层去重(同一问题不同问法)。每层抓的东西不同,要能讲清每步去重依据是什么、误伤了多少。
  • 评测集必须 train/test 隔离并冻结:把评测集与训练集彻底分开、版本化冻结(哈希 + 只读),任何改动都要过评估。隔离不严会导致「模型见过答案」的虚假高分,评估结果失去意义。
学习路径

3.1 SQL 进阶:窗口函数、CTE 与 JSON

2026 年最稀缺的不是会调模型的人,而是能把数据搞干净的人。训练数据、SFT 指令集、评测集、RAG 语料——全都先经过数据管线。多数学到一半放弃的 RAG 项目,死因是数据而不是模型。

sql-- 构造评测集:从生产日志里挑出「有参考改写」且「分布不偏」的样本
WITH ranked AS (
  SELECT
    q.id, q.question, q.answer, q.rewritten_at, q.intent,
    -- 窗口函数①:按意图分区,算最近 30 条的平均质量
    AVG(CASE WHEN f.helpful THEN 1.0 ELSE 0.0 END)
      OVER (PARTITION BY q.intent ORDER BY q.rewritten_at
            ROWS BETWEEN 29 PRECEDING AND CURRENT ROW) AS recent_quality,
    -- 窗口函数②:按意图分区随机排序,用于分层抽样
    ROW_NUMBER() OVER (PARTITION BY q.intent ORDER BY RANDOM()) AS rn
  FROM queries q
  LEFT JOIN feedback f USING (id)
  WHERE q.created_at >= CURRENT_DATE - INTERVAL '30 days'
    AND q.answer IS NOT NULL
)
SELECT * FROM ranked
WHERE recent_quality >= 0.7 AND rn <= 50     -- 每个意图类均匀取样(避免分布偏斜)
ORDER BY intent, rn;

-- JSON 字段查询(模型元数据、工具调用参数常存在 JSON 列里)
SELECT
  payload->>'model'                                   AS model,
  (payload->'usage'->>'completion_tokens')::int       AS out_tokens,
  COUNT(*)                                            AS calls,
  SUM((payload->'usage'->>'total_tokens')::int)       AS tokens
FROM llm_calls
WHERE created_at > now() - INTERVAL '1 day'
GROUP BY 1, 2
ORDER BY tokens DESC;
sql-- 训练/评测隔离:按「用户」哈希切分,防止同源泄漏
WITH base AS (
  SELECT id, user_id, created_at,
         ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS user_seq
  FROM samples
  WHERE created_at < now()
)
SELECT id, user_id, created_at,
  CASE WHEN user_id % 10 = 0 THEN 'test'
       WHEN user_id % 10 = 1 THEN 'valid'
       ELSE 'train' END AS split      -- 同一用户只进一个集合,杜绝泄漏
FROM base
WHERE user_seq <= 20;                 -- 每用户最多取 20 条,抑制头部用户权重

-- 泄漏自检(必须返回 0 行,否则说明同一用户横跨多个 split)
SELECT user_id FROM (
  SELECT user_id, COUNT(DISTINCT split) AS n FROM dataset_view GROUP BY 1
) t WHERE n > 1;

-- 上亿行也别忘了索引与执行计划:EXPLAIN ANALYZE 看是否走了 Seq Scan
EXPLAIN ANALYZE SELECT count(*) FROM samples WHERE created_at > now() - INTERVAL '7 days';
窗口帧写法含义常见错误
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW物理行滑动窗口(7 行)写成 RANGE 会按「值」分组,结果行数与预期不符
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW累计到当前(默认帧)默认帧对并列值会一起算,累计结果与直觉不同
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING前后各一行用于平滑 / 去噪,注意边界返回 NULL
PARTITION BY x ORDER BY y分组内排序忘写 ORDER BY → 排名与累计全部无意义
ℹ
2026 工具:DuckDB 做本地分析,pgvector 做在线检索:本地几千万到上亿行的清洗与统计,DuckDB 可直接以 Parquet 为表跑完整 SQL(SELECT * FROM read_parquet(...) 或 read_json_auto(...)),向量化执行 + 列式存储,常比 pandas 全量加载快一个数量级。在线侧,pgvector 的 HNSW 索引让 Postgres 同时承担业务表与向量检索(避免多套存储),但要注意 m(连接数,常 16–48)与 ef_construction(常 64–256)的取舍:越大召回越高、构建越慢、内存越大。第 3 阶段会把这些参数做成可测量的召回率 / 延迟曲线。

3.2 pandas → polars 与数据质量四查

python# pandas → polars:数据量上到千万行以后的速度差距是数量级的
import polars as pl

df = pl.scan_ndjson('raw/*.jsonl')            # lazy:先规划再执行,可下推谓词
clean = (
    df.filter(pl.col('text').str.len_chars() > 50)
      .with_columns(
          pl.col('text').str.replace_all(r'\s+', ' ').str.strip_chars().alias('text'),
          pl.col('text').str.len_chars().alias('n_chars'),
      )
      .unique(subset=['text'], keep='first')   # 精确去重
      .collect()
)
# 近重复(MinHash)与语义重复(embedding 聚类)要另做,别指望 unique() 解决
# —— 这一块会在第 3 阶段的概率数据结构里正式实现

# 数据质量四查:每次数据更新都跑一遍,输出可存档的报告
def quality_report(path: str) -> dict:
    df = pl.scan_ndjson(path)
    return {
        'total':     df.select(pl.len()).collect().item(),
        'null_rate': df.select([pl.col(c).is_null().mean().alias(c) for c in COLS]).collect().to_dicts()[0],
        'len_p50':   df.select(pl.col('text').str.len_chars().median()).collect().item(),
        'len_min':   df.select(pl.col('text').str.len_chars().min()).collect().item(),
        'lang_dist': df.group_by(pl.col('lang')).len().collect().to_dicts(),
        'dup_exact': df.select(pl.len()).collect().item()
                     - df.unique(subset=['text']).select(pl.len()).collect().item(),
        'empty_text': df.filter(pl.col('text').str.strip_chars().str.len_chars() == 0)
                        .select(pl.len()).collect().item(),
    }
# 把这四个维度(空值 / 长度分布 / 语言分布 / 重复率)固化成一个脚本,
# 每次数据版本更新都跑,把报告存进 docs/data/ 作为数据卡的一部分。
python# pandas vs polars:同一份约 500 万行文本清洗的实测差距
import time, pandas as pd, polars as pl

t0 = time.perf_counter()
pdf = pd.read_json('raw.jsonl', lines=True)
pdf = pdf[pdf['text'].str.len() > 50]
pdf['text'] = pdf['text'].str.replace(r'\s+', ' ', regex=True).str.strip()
pdf = pdf.drop_duplicates(subset=['text'])
print('pandas', round(time.perf_counter() - t0, 2), 's', len(pdf))
# 预期: pandas 18.4 s  4210933

t0 = time.perf_counter()
ldf = (pl.scan_ndjson('raw.jsonl')          # lazy:谓词 / 投影下推
         .filter(pl.col('text').str.len_chars() > 50)
         .with_columns(pl.col('text').str.replace_all(r'\s+', ' ').str.strip_chars())
         .unique(subset=['text'])
         .collect())
print('polars', round(time.perf_counter() - t0, 2), 's', ldf.height)
# 预期: polars 2.1 s  4210933   ← 约 8–10x,峰值内存也显著更低
去重层级方法能抓什么成本 / 经验取值
精确去重unique / drop_duplicates逐字节相同的样本O(n),最便宜,必做
近似去重MinHash + LSHJaccard 相似度 ≥ 阈值(常 0.8)的近重复需 sketch,内存可控,Web 语料常再删 20–50%
指纹去重SimHash汉明距离 ≤ 3 的近重复快,但对长文改写不敏感
语义去重embedding 聚类 + 阈值语义相同但字面不同的样本贵(要过模型),仅高质量语料用
★
2026 案例:polars 1.x + 流式引擎支撑「外存也能跑」:2026 年 polars 的 API 已稳定,streaming engine 让超过内存的数据集可分批处理而不必全量加载,配合 scan_parquet 直接对列式文件做谓词下推。真实量级:Web 级语料清洗中,精确去重 + MinHash 近重复合计常能删掉 30–70% 的原始文本;Qwen3 / Llama 4 / DeepSeek-V4 这类模型的数据报告里,去重与质量过滤都是「决定最终能力」的前置环节,而不是收尾步骤。记住:模型能力的天花板,很大程度由数据管线决定——同一套训练代码,换一版更干净的数据,评测分数常能差出好几个点。

3.3 动手练习与自测(数据工程与 SQL)

★
练什么 / 怎么算过:5 题,做完应能独立产出一份「可复现、无泄漏、有数据卡」的清洗与评测集构造流程。
  1. 写一条 SQL,从样本表按「用户哈希」切成 train/valid/test,并附一条泄漏自检查询。判据:用 CASE WHEN user_id % 10 ... 或哈希取模按用户切;自检用 GROUP BY user_id HAVING COUNT(DISTINCT split) > 1 必须为空。
  2. 解释 ROWS BETWEEN ... 与 RANGE BETWEEN ... 的区别,并说明默认帧是什么。参考答案:ROWS 按物理行,RANGE 按排序「值」;默认帧是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,对并列值会一起纳入,是累计结果与直觉不符的常见原因。
  3. 为什么随机抽样构造评测集通常不可取?判据:高频意图会淹没有效样本,分布偏斜;应按意图 / 长度 / 语言 / 难度分层各取 N 条。
  4. 用 polars 写出「读 JSONL → 过滤 → 正则归一 → 精确去重」的 lazy 管线;说明为何比 pandas 快。判据:用 scan_ndjson + filter + with_columns + unique + collect;快在 Rust 多核并行 + 惰性谓词 / 投影下推 + Arrow 列式布局。
  5. 精确去重、MinHash、embedding 聚类分别解决什么、代价如何?判据:精确抓逐字节重复(最便宜);MinHash 抓高 Jaccard 近重复;embedding 抓语义重复但最贵。三者是递进的成本—收益关系,通常按此顺序叠加使用。
自测项合格线优秀线
SQL 能力能写窗口函数 + CTE 完成排名与分层抽样能看懂执行计划并对上亿行查询做索引优化
防泄漏按用户 / 时间隔离 train 与 test含可自动运行的泄漏自检 SQL 并进 CI
数据管线polars lazy 管线 + 质量四查报告三级去重 + 数据卡 + 可复现版本哈希

4. Git / Linux / Docker / CI:部署前的最后一公里

知识结构图 · Git / Linux / Docker / CI
部署前的最后一公里3 大知识域 · 20 个知识点
Git 工作流分支策略与 PR 流程rebase / cherry-pick大文件与 Git LFS.gitignore 与密钥防泄漏commit 语义与版本回溯
学习路径
  1. 读 4.1:搭分支策略(protect main + 短生命周期特性分支 + PR)
  2. 跑一次 rebase / cherry-pick,把 .gitignore 补全(含 .env 与权重)
  3. 验证大文件不进 git:权重用 Git LFS 或对象存储 + 版本清单
  4. 对接 M1:提交 uv.lock 与数据版本哈希,确认 CI 全绿
✔ 能讲清 AI 项目里为何权重/密钥绝不该进 git,并完成一次安全的分支合并
核心知识点详解
  • AI 项目里权重与密钥绝不能进 git:模型权重(几十 GB)会撑爆仓库、clone 极慢;密钥(.env、API token)一旦进历史就永久泄露(哪怕删除,从 commit 历史仍可恢复)。权重走 Git LFS 或对象存储 + 版本清单,密钥靠 .gitignore + CI 扫描阻断。
  • 分支策略三件套:main 受保护不可直接 push;短生命周期特性分支(feat/xxx)+ PR 审查合并;rebase 保持历史线性、cherry-pick 定点抽取提交。AI 迭代快,更要用 PR 门禁在合入前跑 CI。
  • commit 语义与版本回溯:一次 commit 只做一件事,写明「为什么」。配合 tag + 版本清单,出问题时能 git log 快速定位哪次改动引入回归,能安全 revert 单次提交。
Linux 与 Shell进程与信号 SIGTERM / SIGKILLtop / htop / ps 排障grep / awk / sed 文本处理df / du / ss 资源排查无 GUI 服务器定位问题
学习路径
  1. 读 4.2:先会看进程与信号,分清 SIGTERM 可优雅退出、SIGKILL 直接杀
  2. 用 top / ss / df / du 在一台服务器上定位「慢/卡/满」三类问题
  3. 用 grep / awk / sed 从日志里抽出错误行与统计
  4. 对接 M1/M2:实操定位并排查一次 M1 管线故障(卡住/慢/丢数据)
✔ 能给一句报错快速定位到进程/磁盘/端口/日志线索,而非盲目重启
核心知识点详解
  • 信号是优雅停服的底线:SIGTERM(15)让进程自己清理后退出,容器编排、docker stop 都先发它;SIGKILL(9)强制杀死、无法捕获。给服务配 SIGTERM 处理器做优雅停机(冲刷日志、释放连接、保存断点),是生产服务的标配。
  • 「卡/慢/满」三类问题的排查命令:卡→py-spy dump -p 看线程栈;慢→top / htop 看 CPU 与负载;满→df -h 磁盘 / free -h 内存 / ss -tlnp 端口占用。先定位再动手,别盲目重启掩盖问题。
  • 文本处理三板斧:grep -n 抽错误行、awk 按列统计与格式、sed 原地替换。日志排障常组合它们抽出错误占比与分布,例如 grep ERROR | awk "{print $1}" → awk 抽首列后 sort | uniq -c 统计分布。
Docker 与 CI多阶段 Dockerfile镜像层缓存与体积优化docker compose 编排CI 流水线设计一条命令起服务构建制品与缓存
学习路径
  1. 读 4.3:写一个多阶段 Dockerfile(构建态 vs 运行时分离)
  2. 跑内置示例,docker build 看层缓存复用,用 --layers 方式核对体积
  3. 写一条 CI:lint → test → build → push,缺一层就失败
  4. 对接 M1:docker compose up 一条命令在本机把后端起起来
✔ 能不查文档写出多阶段 Dockerfile,并在干净机器上一条命令起服务
核心知识点详解
  • 多阶段构建:构建态与运行时分离:第一阶段装编译依赖 / 全量包(构建态),第二阶段只拷贝产物 + 最小运行依赖(运行时),镜像体积可从 GB 级降到几十 MB。GPU 场景记得选 runtime 而非 devel 基础镜像并配 NVIDIA Container Toolkit。
  • 镜像层缓存的顺序陷阱:镜像按层缓存,变更频繁的层越靠后,前面的层才能复用。先装依赖(复制 pyproject/锁文件并 pip install)再复制源码,改一行代码只重建最后一层,构建时间大幅下降。
  • 一条命令起服务的元凶是编排:docker compose up 把后端 / 向量库 / 缓存 / 数据库一起编排,一次起干净。注意容器内 localhost 不是宿主机,跨容器用服务名,开发热更要用 volume 挂载。
  • CI 流水线是质量门禁:lint → test → build → push 四段,任一段失败即阻断合并。配合分阶段跑(单元快、端到端慢)、缓存依赖层、按分支触发,让每次 PR 都有可复现的绿色保障。
学习路径

4.1 Git 工作流与 AI 项目的特殊要求

bash# 高频命令(AI 项目里最常用的那些)
git switch -c feat/retrieval-hnsw          # 建特性分支
git add -p                                  # 逐块暂存,避免把调试代码一起提交
git commit -m 'feat(index): 实现 HNSW 索引与召回率基准'
git rebase -i main                          # 整理提交历史(不要 rebase 已推送的公共分支)
git cherry-pick <sha>                       # 把某个修复搬到另一个分支
git stash push -m 'wip: 调参中的实验'        # 临时保存实验状态
git log --oneline --graph --all -20
git diff --stat HEAD~3                      # 看最近三次改了哪些文件

# 事故恢复
git reflog                                  # 「我丢了 commit」的救命稻草
git switch -c rescue <sha>                  # 从 reflog 找到的 sha 恢复

# 检查有没有不小心提交敏感文件
git log --all --full-history -- '*.env' '*.pem' '*credentials*'
git secrets --scan-history   # 或用 gitleaks

# 加 pre-commit 钩子(把 lint / 格式化 / 密钥扫描自动化)
cat > .pre-commit-config.yaml <<'YAML'
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.6.0
    hooks: [{ id: ruff }, { id: ruff-format }]
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks: [{ id: gitleaks }]
YAML
pre-commit install
bash# ① 定位「哪次提交把指标搞坏了」:git bisect 自动二分
git bisect start HEAD v1.2.0            # 坏的一端 HEAD,好的一端 v1.2.0
git bisect run ./scripts/eval_gate.sh   # 脚本退出码非 0 即判为「坏」,自动二分
# 100 个提交里藏一个回归,最多约 log2(100)≈7 次就定位到

# ② 并行开发多个实验分支,不要来回 stash:用 worktree
git worktree add ../orion-exp1 feat/lora-rank   # 另开目录检出分支

# ③ 误提交大文件后清理历史(谨慎:会重写历史,需团队同步强推)
git filter-repo --path data/ --invert-paths     # 或 BFG Repo-Cleaner

# ④ 强约定:提交信息可读,便于将来二分
#   <type>(<scope>): <subject>
#   例如  fix(rag): 修正 chunk 重叠导致重复召回
场景命令要点
找回丢失提交git reflog 然后 git switch -c rescue reflog 默认保留 90 天,是最常用的救命稻草
逐块暂存git add -p避免把调试 print / 临时密钥一起提交
临时保存实验git stash push -m msgstash 不作备份,别当长期存储
清理未跟踪文件git clean -nd 先预览,再 -f防误删 data/ 与 checkpoint
查看某行历史git log -L 10,20:file.py定位某个 bug 引入行极有用
✔
2026 实践:把 Prompt 与配置当作「可 diff 的代码」:AI 项目与普通项目最大的差异是「行为」散落在 Prompt、采样参数、检索参数里。把它们放进 configs/ 与 prompts/ 并纳入 git,才有 git log -L、git bisect 的用武之地。用 prompts/chat.v3.md 这种带版本号的文件名,或在模板里写死 PROMPT_VERSION 并打进请求日志,你才能在「指标掉了」时做到 5 分钟内回溯,而不是几天的猜测。

4.2 Linux 与 Shell:无 GUI 服务器上的排障

bash# ── 进程与端口(「服务起来了但访问不到」的标准排查顺序)──
ss -ltnp | grep 8000          # 端口到底有没有监听?监听在 0.0.0.0 还是 127.0.0.1?
ps aux | grep -i hamauls_orion        # 进程在不在?是不是启动后就退了
lsof -p <PID> | head -40      # 这个进程打开了哪些文件与端口
strace -p <PID> -f -e trace=network   # 卡住时看它在做什么系统调用

# ── 资源与显存 ──
df -h / free -h / uptime      # 磁盘 / 内存 / 负载
nvidia-smi                    # 显存是否被吃满、有没有别的进程占着卡
watch -n1 nvidia-smi          # 观察推理或训练是否真的在跑(利用率是否在跳)
nvidia-smi --query-compute-apps=pid,used_memory --format=csv   # 谁占了显存

# ── 日志(不要 cat 一个 10GB 的日志)──
tail -F app.log                      # 实时跟踪
grep -n 'ERROR\|Traceback' app.log | tail -50
journalctl -u hamauls-orion -f --since '10 min ago'
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head   # 统计访问来源

# ── 传大文件(别用 scp 传几十 GB 模型)──
rsync -avhP --partial --inplace ./ckpt/ user@host:/data/ckpt/    # 支持断点续传
# ── 定时与并发控制 ──
nohup python train.py > train.log 2>&1 & echo $! > train.pid     # 后台跑并记 PID
flock -n /tmp/train.lock python train.py                          # 防止重复启动
✔
一个值得养成的习惯:遇到「服务异常」时,固定按同一套顺序走:进程在不在 → 端口监听没 → 资源够不够 → 日志报什么 → 依赖通不通。把顺序固定下来,排查速度会快一个数量级,因为你不是在「想下一步查什么」,而是在「执行清单」。这套清单在第 4 阶段的网络排障里还会被扩展。
bash# ── 判断瓶颈在哪:CPU / 内存 / 磁盘 / 网络,四选一 ──
vmstat 1 5            # r>核数→CPU 排队;b>0→阻塞;si/so 非 0→在换页(危险)
iostat -xz 1          # %util 接近 100 且 await 高 → 磁盘饱和
sar -n DEV 1 5        # 网卡是否打满(对比链路带宽)
pidstat -p <PID> 1    # 单进程的 CPU / IO 分解
# 经验判据:r 长期 > 2×核数 = CPU 瓶颈;
#          si/so 持续非 0 = 内存不足在 swap(延迟灾难)

# ── 服务被 OOM Killer 杀掉后怎么确认 ──
dmesg -T | grep -i 'killed process'      # 会有 Out of memory: Killed process <pid>
journalctl -k --since '1 hour ago' | grep -i oom

# ── 提高文件描述符上限(高并发服务必调)──
ulimit -n                                # 默认常是 1024,千级并发会报 too many open files
cat /proc/<PID>/limits | grep 'open files'
# 永久修改:/etc/security/limits.conf 设 nofile 65535,或 systemd 的 LimitNOFILE=
信号含义程序该做什么
SIGTERM (15)优雅停止停止收新请求、跑完在途、落盘、退出(k8s 默认先发它)
SIGINT (2)Ctrl-C 中断同 SIGTERM,交互式场景常用
SIGKILL (9)强杀,不可捕获无法清理——所以别指望用它做「优雅退出」
SIGUSR1 / 2用户自定义常用于「重开日志」「dump 状态」而不重启
⚠
失败模式:进程活着但服务已经不可用:最常见的线上事故不是进程崩溃,而是「活着但打不开」:端口监听在 127.0.0.1 而非 0.0.0.0(容器外访问不到)、事件循环被同步调用卡死(py-spy dump 一看就明)、显存被别的进程占满导致推理 hang、磁盘写满导致日志与 checkpoint 全失败。所以健康检查不能只 ping 端口,要真正打一次业务路径(探活 liveness 与探就绪 readiness 分开),这也是第 4、16 阶段服务化的起点。

4.3 Docker 与 CI:一条命令跑起来

dockerfile# 多阶段构建的 Dockerfile(分层缓存 + 非 root 运行 + 依赖与代码分离)
FROM python:3.12-slim AS builder
ENV PIP_NO_CACHE_DIR=1
WORKDIR /app
# ① 先装依赖(变更少)再拷代码(变更多)—— 充分利用镜像层缓存
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev

FROM python:3.12-slim AS runtime
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
ENV PATH='/app/.venv/bin:$PATH'
COPY src/ ./src/
COPY configs/ ./configs/
# ② 非 root 运行(安全基线,很多合规检查会卡这一条)
RUN useradd -m app && chown -R app:app /app
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \
  CMD python -c 'import urllib.request,sys; sys.exit(0 if urllib.request.urlopen("http://localhost:8000/health").status==200 else 1)'
CMD ['uvicorn', 'hamauls_orion.serving.api:app', '--host', '0.0.0.0', '--port', '8000']
yaml# .gitlab-ci.yml —— M1 的验收条件之一:CI 全绿
stages: [test]

test:
  stage: test
  image: python:3.12-slim
  before_script:
    - pip install uv                       # GitLab Runner 里装 uv
  script:
    - uv sync --frozen            # 严格按锁文件安装,保证可复现
    - uv run ruff check src tests        # 静态检查
    - uv run ruff format --check src tests
    - uv run mypy src                    # 类型检查
    - uv run pytest -q -m 'not slow' --cov=src --cov-fail-under=70
    # 覆盖率门槛:不是为了数字好看,而是防止「新代码完全没测」
★
阶段验收标准(= 里程碑 M1):你能在一个干净的机器上,git clone && docker compose up 之后,一条命令把「数据清洗 → 模型调用 → 结果落盘 → 服务暴露 HTTP 接口」整条链路跑起来,并且失败后能定位到是哪一环。同时 Hamauls Orion 仓库的 CI(ruff + mypy + pytest)全绿。做到这一点,本阶段就可以结束,进入计算机组成原理。
yaml# docker-compose.yaml —— 一键起本地依赖,让「clone 即开发」成立
services:
  api:
    build: { context: ., dockerfile: docker/Dockerfile }
    ports: ['8000:8000']
    env_file: [.env]
    depends_on:
      postgres: { condition: service_healthy }   # 等依赖真的就绪,而非「起来了」
    volumes: ['./data:/app/data:ro']             # 代码进镜像,数据挂卷
  postgres:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_PASSWORD: dev
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      retries: 10
  redis:
    image: redis:7-alpine
    command: ['redis-server', '--maxmemory', '512mb', '--maxmemory-policy', 'allkeys-lru']
写法镜像体积量级说明
python:3.12(全量)约 1.0 GB含编译工具链,仅适合 builder 阶段
python:3.12-slim + 多阶段约 250–400 MB生产 runtime 的常用起点
只拷 .venv 与 src/比全量小 5–10x不含缓存与测试依赖
nvidia/cuda:12.x-devel约 6–9 GB仅编译 CUDA 时用
nvidia/cuda:12.x-runtime约 2–3 GB运行推理的推荐基础镜像
★
2026 实践:uv 进容器 + 挂载缓存,构建从分钟到秒:在 Dockerfile 里用 uv sync --frozen --no-dev,并配 BuildKit 的 --mount=type=cache,target=/root/.cache/uv,依赖层可复用缓存;配合 .dockerignore 排除 .venv、data/、*.pt,上下文传输也大幅变小。千万不要 COPY . . 进 runtime 层——它会把权重、日志、密钥一起打进镜像。GPU 场景务必把权重放卷或运行时从对象存储拉取:一个 70B 模型权重几十 GB,塞进镜像后每次推送都是灾难;镜像应是「可复现的代码环境」,而不是「数据仓库」。

4.4 动手练习与自测(Git / Linux / Docker / CI)

★
练什么 / 怎么算过:5 题,做完应能在无 GUI 服务器上独立定位问题,并让整条链路「一条命令跑起来」。
  1. 服务「起来了但外部访问不到」,写出你的排查清单与判据。参考答案:ss -ltnp | grep 看是否监听及绑定地址(0.0.0.0 vs 127.0.0.1)→ ps aux 看进程是否退出 → free -h / df -h 看内存磁盘 → tail -F / journalctl -u 看日志 → 再查防火墙与依赖连通性。
  2. 用 git bisect 在 128 个提交里定位一个回归,最多需要多少次验证?判据:约 log2(128)=7 次;配合 git bisect run <脚本> 可自动二分(脚本退出码非 0 判为「坏」)。
  3. 把「装依赖」放在「拷代码」之前为什么能显著加快构建?参考答案:Docker 按层缓存,代码改动只影响后面的层;若顺序反了,任何代码改动都会使依赖层缓存失效、重装全部依赖,构建从几秒变回几分钟。
  4. GPU 容器要做什么额外配置?基础镜像怎么选?判据:宿主装 NVIDIA Container Toolkit,运行时 --gpus all;基础镜像选 nvidia/cuda:...-runtime(约 2–3 GB)而非 -devel(约 6–9 GB),后者仅编译 CUDA 时才需要。
  5. 进程「活着但不可用」有哪些典型成因?判据:监听地址错(127.0.0.1)、事件循环被同步调用阻塞、显存被占满、磁盘写满、依赖未就绪;对策是健康检查真正打业务路径,并区分 liveness 与 readiness。
自测项合格线优秀线
Git 工作流分支 + PR + pre-commit 能拦住常见问题会用 bisect / worktree / filter-repo 处理复杂场景
Linux 排障能按固定顺序查到进程 / 端口 / 资源 / 日志会用 vmstat / iostat 区分 CPU、内存、磁盘瓶颈
容器与 CI多阶段构建 + compose 一键起依赖镜像瘦身到 slim 级、GPU 用 runtime 基础镜像、CI 全绿

项目里程碑

贯穿项目 · Hamauls Orion
M1 仓库骨架与并发数据管线 第 3–6 周

从第 1 阶段起就建立 hamauls-orion/ 单体仓库:uv 管依赖、src 布局、类型标注、pytest、GitLab CI、Dockerfile;并写出第一个真实组件——并发调用模型 API 的评测/标注管线(限流 + 指数退避 + 断点续跑)。

本阶段产出(直接进入项目仓库)
验收标准:在干净机器上 git clone && docker compose up 能跑通;CI 全绿;并发管线跑 1 万条样本不丢不重、中断后可续跑。

阶段练习项目

PROJECT 1
并发评测流水线(M1 核心)
写一个批量调用模型 API 的评测脚本,跑 1 万条样本验证不丢不重、中断后可续。
要达成的效果
  • 在 1 万条样本上跑通,结果无丢失、无重复
  • 任意时刻 Ctrl+C 或用 SIGINT 中断,重启后能从断点继续,不重跑已完成样本
  • 输出结构化的 P50/P95 延迟与 token 成本统计报表
功能需求
  • 用 asyncio + asyncio.Semaphore 控制并发上限,从命令行参数读取(默认合理值)
  • 对 429 / 超时 / 连接错误 / 5xx 走指数退避 + 抖动重试;对 401 / 403 / 400 立即失败并告警,不做无意义重试
  • 每完成一条样本立即 append 写一行 JSONL,启动时读入已完成 key 以支持断点续跑
  • 请求失败但语义上可跳过的样本,记录原因并计入统计,而非静默吞掉
  • 用 pydantic-settings 从环境变量注入模型名、max_new_tokens 等配置,.env 不提交
交付物
  • scripts/evals/run_pipeline.py:可运行的并发评测 CLI
  • tests/:限流、重试分类、断点续跑三组的单元 / 集成测试
  • 一份运行报告(样例大小、延迟分布、成本、错误分类)
边界 · 不做

不做评测集打分逻辑(那是数据集清洗工具与 M6 的职责);不做 GPU 并行推理。

PROJECT 2
数据集清洗工具
用 polars + SQL 把一份真实文本语料清洗成可复现、可回溯的训练/评测集候选。
要达成的效果
  • 输出一份固定版本的清洗数据 + 数据版本哈希,同一次输入在任意机器重跑得出完全一致的版本号
  • 生成清洗报告:每步抓了多少、误伤多少、最终保留多少
  • 产出一张数据卡(来源、许可、清洗步骤、已知偏差)
功能需求
  • 用 polars 的 LazyFrame 处理,避免 for 循环逐行操作
  • 精确去重 → 长度过滤 → 语言过滤,每一步都记录数量并说明依据
  • 必做「数据质量四查」:完整性 / 一致性 / 唯一性 / 时效性
  • 使用 SQL 完成至少一次去重或分组统计(如窗口函数挑「每组最新一条」)
交付物
  • scripts/data/clean.py + tests/test_clean.py
  • 清洗报告(MD)+ 数据卡(datasheet)
  • 版本化后的清洗数据文件(含内容哈希清单)
边界 · 不做

不做模型评测打分;不做模糊 / 近重复去重的超量工程,先保证精确去重与质量四查到位。

PROJECT 3
流式对话最小产品
FastAPI 后端 + TypeScript 前端,做一个能流式输出、可停止、可容错的最小对话产品。
要达成的效果
  • 端到端流式:后端逐 token 推送,前端逐字渲染,停顿不卡顿
  • 用户可点击「停止生成」,后端真正取消模型调用(不烧 token),前端进入 correct 状态
  • docker compose up 一条命令在本机整体启动
功能需求
  • 后端 FastAPI 以 SSE 流式返回,支持心跳保活与正确的连接中断处理
  • 前端每条消息处于显式状态机 pending / streaming / done / error
  • 区分可重试(5xx / 断网)与不可重试(4xx)错误,做降级提示而非崩溃
  • 后端在做模型调用前/后用 Pydantic 校验入参与输出,前端用共享类型对接
交付物
  • services/api + services/web 源码,docker-compose.yml
  • 前端导出的构建产物与本地联调说明
  • 一条「建立连接 → 流式 → 停止 → 错误降级」的最小演示录屏或截图
边界 · 不做

不做多用户会话 / 鉴权 / 持久化;不做 Markdown 增量渲染的极致性能优化。

PROJECT 4
Hamauls Orion 仓库骨架
搭起贯穿项目的统一仓库骨架,成为后续 16 个阶段所有代码的共同载体。
要达成的效果
  • git clone && docker compose up 在干净机器上一条命令把后端起起来
  • uv + ruff + mypy + pytest 在 CI 全程全绿
  • 后续 16 个阶段的新代码能直接落进约定布局,无需重构骨架
功能需求
  • src/hamauls_orion/{core,data,models,index,rag,agent,serving,evals} 约定布局 + configs/ + tests/ + scripts/
  • uv.lock 已提交;.env 与模型权重在 .gitignore 中
  • Makefile 提供 make install / make test / make lint / make up 入口
  • 多阶段 Dockerfile + docker-compose.yml,一行启动
交付物
  • hamauls-orion/ 仓库(含布局、配置、CI、Dockerfile)
  • README(快速上手)+ 架构约定说明
  • CI 配置(GitLab 或 GitHub Actions)
边界 · 不做

本阶段只交付骨架与第一个可跑组件,不做模型推理 / 检索 / Agent 等业务模块(留给后续阶段)。

常见误区

面试高频问题速答

Python 的 GIL 是什么,对 AI 场景有什么影响?

GIL(全局解释器锁)让同一进程内同一时刻只有一个线程执行 Python 字节码,所以多线程无法并行做 CPU 计算——但它会在 I/O 等待(网络、磁盘)时释放,也会在 numpy / torch 这类 C 扩展执行时释放(它们在 C 层主动释放 GIL)。结论:模型 API 调用、文件与网络 I/O 用 asyncio 或多线程都有效;而 tokenize、纯 Python 数值计算这类 CPU 密集任务必须用多进程(ProcessPoolExecutor)或换成向量化 / C 扩展。注意:CPython 3.13+ 的 free-threaded 构建(PEP 703)正在逐步解除这个限制,但生态适配仍在进行中,生产环境暂不宜依赖。

asyncio 和线程池怎么选?

看依赖的接口形态。① 全链路可 async(aiohttp、asyncpg、MCP 的 async 客户端)→ 用 asyncio,单机可支撑上千并发连接,内存开销小(每个协程几 KB,而每个线程约 1 MB 栈)。② 必须调用只有同步接口的库(部分 SDK、某些数据库驱动)→ 用 asyncio.to_thread 或线程池包一层,把阻塞调用挪出事件循环。③ CPU 密集(tokenize、特征计算)→ ProcessPoolExecutor 或多进程。实际项目里常常三者混用:asyncio 做骨架、to_thread 包同步库、进程池做重计算。

怎么保证 LLM 应用的输出可控?

三层防御:① 约束生成——用结构化输出(JSON Schema / function calling / grammar 约束解码)让模型只能在合法空间里输出;② 边界校验——在系统边界用 Pydantic 严格校验,校验失败走降级或有限重试,而不是崩溃;如果模型输出要渲染成 HTML,还必须走安全的 markdown 渲染防 XSS;③ 回归门禁——用 Evals(黄金数据集 + 结构化断言 + LLM-as-Judge)在 CI 里做门禁,指标下降就阻断合并,而不是靠人工目测。这三层缺任何一层,都会在真实流量下暴露问题。

一次「效果变差」的线上问题,你的排查顺序?

先对齐变量:数据版本、Prompt 版本、模型版本、采样参数有没有变——绝大多数问题在这里,所以第一步永远是「有没有可回溯的实验记录」。再查链路:检索是否召回变差、上下文是否被截断、工具调用是否失败、是否命中了缓存返回了旧结果。再看流量分布:是否出现了新的输入分布(新用户群体、新语言、超长输入)。最后才怀疑模型本身。这个顺序的价值在于:它把「最可能的原因」排在最前面,避免在低概率方向上浪费几小时。

Docker 在 AI 项目里的价值是什么?有哪些坑?

价值:① 解决「我这能跑你那儿不能跑」——固定 CUDA / Python / 依赖版本;② 镜像分层缓存让构建与部署快;③ 配合 compose 一键起本地依赖(向量库、缓存、数据库);④ 让实验环境与生产环境尽量一致。坑:① GPU 场景需要 NVIDIA Container Toolkit 与 `--gpus`,且必须选 `runtime` 而非 `devel` 基础镜像控制体积;② 镜像里不要放模型权重(体积巨大且更新频繁),应该运行时挂载卷或从对象存储拉取;③ 容器内 `localhost` 不是宿主机的 localhost,网络模型要理清;④ 非 root 运行时挂载卷的权限问题很常见;⑤ 构建机与运行机的 CPU 指令集差异(AVX 支持)可能导致「镜像能跑但 numpy 报 illegal instruction」。

为什么项目要把源码放在 src/ 目录下?

因为 Python 的模块查找会把「当前工作目录」加入 sys.path。如果源码直接在仓库根目录,那么 `python scripts/run.py` 时 `import hamauls_orion` 会在本地目录直接命中,即使项目的打包配置是错的也能跑通——于是问题被推迟到安装之后才暴露(比如漏了包数据、`packages` 配置不对、循环依赖)。`src/` 布局强制你以「已安装的包」方式测试(通常配合 `pip install -e .`),能提前发现打包问题。这在需要发布内部包或部署到容器的项目里是必须的。

学习资源

Python 官方文档 · asyncio文档 docs.python.org/3/library/asyncio.html 异步编程权威参考,重点看 TaskGroup(3.11+,正确的取消语义)、Semaphore、as_completed、to_thread。 Fluent Python(第 2 版)书 www.oreilly.com/library/view/fluent-python-2nd/9781492056348/ 把 Python 从「会写」提升到「懂为什么」的必读书。重点章节:数据模型、装饰器、并发模型(含 GIL 的准确解释)。 uv — 极速 Python 包管理文档 docs.astral.sh/uv/ 2026 年新建项目的默认选择,安装、锁依赖、管理虚拟环境都很顺,速度比 pip 快一个数量级。 Polars 用户指南文档 docs.pola.rs/ lazy 执行 + 多核并行,大数据量文本处理比 pandas 快一个数量级。重点看表达式 API 与惰性求值章节。 pydantic / pydantic-settings 文档文档 docs.pydantic.dev/latest/ 运行时校验与配置管理的事实标准。重点看:模型校验、自定义校验器、Settings 的环境变量注入。 py-spy — 生产环境性能剖析仓库 github.com/benfred/py-spy 低开销、无需改代码、能附加到运行中的进程。服务「卡住但没报错」时的第一工具。 TypeScript Handbook文档 www.typescriptlang.org/docs/handbook/intro.html 系统过一遍类型系统。AI 应用层的主力语言,重点看泛型、类型收窄、以及 async 迭代器。 MDN — 使用 Server-Sent Events文档 developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events SSE 的权威参考。做流式 AI 前端必读:事件格式、断线重连、与 WebSocket 的对比。 Docker 官方 Get Started + 多阶段构建文档 docs.docker.com/get-started/ 镜像、容器、compose、数据卷的基础;多阶段构建与 GPU 部分看 NVIDIA Container Toolkit。 SQLBolt / PostgreSQL 教程(窗口函数)教程 sqlbolt.com/ 快速补 SQL;进阶重点看窗口函数、CTE、以及 JSON/JSONB 的查询与索引。构造数据集离不开。 GitLab CI 文档 + pre-commit文档 docs.gitlab.com/ee/ci/ 把 lint、类型检查、单测、覆盖率门槛做成 CI 门禁。配合 pre-commit 在本地就拦住问题,比在 CI 上返工快得多。 The Twelve-Factor App文档 12factor.net/ 十二要素应用方法论。看似老派但每一条都还成立:配置与代码分离、日志作为事件流、无状态进程、开发/生产一致性。工程素养的底层清单。
★
2026 形势提示:2026 年 AI 岗位的分水岭不再是「会不会用模型」,而是能不能把模型变成可靠的系统。招聘数据里 Agentic AI 相关岗位需求增长极快,而这些岗位的共同硬门槛就是本阶段的工程能力。而且从这一阶段起,你不是在做一个孤立的练习——你在搭 Hamauls Orion 的仓库骨架(M1),后面 16 个阶段的所有代码都会长在这个骨架上。把「能交付」当成习惯,后面的每一步都会快很多。