编程与工程基础 Engineering Foundations
这是整条路线唯一可以「快进但不可以跳过」的阶段。2026 年的 AI 工程师交付的不是一个 notebook,而是「模型 + 工具 + 数据 + 服务」的完整系统:训练脚本要能在集群上断点续跑,推理服务要能扛住并发,Agent 要能调工具、存状态、失败了能恢复,评测要能进 CI 当门禁。这些能力的地基全在这里。而且从这一阶段开始,你就要建立整个路线的载体——贯穿 17 个阶段的 Hamauls Orion 项目仓库(里程碑 M1)。
阶段总览
- 写出类型清晰、有结构化日志、有单测、配置与代码分离的 Python 工程代码,而不是散装脚本
- 掌握 asyncio / 线程 / 进程三种并发模型,能写出带限流、分类重试、断点续跑的高吞吐数据管线
- 会用 Pydantic 在系统边界做校验,理解「LLM 输出是不可信输入」这条铁律
- 能用 TypeScript 消费 SSE 流,实现流式渲染、停止生成、错误重试与消息状态机
- 掌握 SQL 的窗口函数与 CTE,能独立完成数据清洗、去重、抽样与评测集构造
- 熟练使用 Git / Linux / Shell / Docker,能在无 GUI 的服务器上定位问题
- 会写多阶段 Dockerfile 与 CI 流水线,做到「一条命令跑起来」
- 搭起 Hamauls Orion 仓库骨架:src 布局、uv 锁依赖、ruff/mypy/pytest 全绿、CI 生效
| 周次 | 主题 | 交付物 |
|---|---|---|
| 第 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 收口) | 容器化 + 编排,一条命令起服务;一份剖析报告 |
1. Python 工程能力
学习路径
- 读 1.1:跟着代码把 Settings / dataclass / 异常分层抄一遍
- 跑内置代码,改为从环境变量注入 model_name 与 max_new_tokens
- 把 retry 分类补进异常处理(含退避)
- 对接 M1:用 Pydantic 校验评测管线入参并落 JSONL
核心知识点详解
- 类型标注只是协议,不是约束: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 跳过,实现「断电重启不丢不重」,是大规模评测管线的标配。
学习路径
- 读 1.2:理解 GIL 只在 I/O 时释放,确定「并发数不是越大越好」
- 跑 Semaphore + asyncio.gather 的限流示例并观察耗时曲线
- 用排队论给并发上限定档(对比表里的 60–90% 现象)
- 完成 M1:并发管线支持限流 + 重试分类 + 统计 P95
核心知识点详解
- 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/ 进程池,否则会卡死事件循环。
学习路径
- 读 1.3:照着搭 uv + src 布局 + pyproject 例子
- 提交 uv.lock,配置 ruff + mypy + pre-commit
- 为一条函数写单测,按表格里的「分层门槛」定覆盖率
- 对接 M1:让 hamauls-orion 骨架 CI 全绿(ruff+mypy+pytest)
核心知识点详解
- uv + uv.lock 是可复现的基石:
uv.lock固定每个依赖的精确版本与哈希,保证「今天能跑、下周还能跑」。必须提交进 git——不提交则不同机器 / 不同时间解析出不同版本,产生「薛定谔的可复现」。 - src/ 布局是工程化的分水岭:源码放
src/会强制以「已安装的包」方式测试(pip install -e .),能提前暴露打包问题(漏包数据、packages 配置错、循环依赖),不因「当前目录正好被加入 sys.path」而侥幸通过。 - pytest 三类测试各司其职:单元测试(单函数,快)→ 集成测试(跨模块,验证契约)→ 端到端(起服务,慢但真)。用
@pytest.mark分 tag,CI 里分层跑,别让慢测试拖慢每次提交。 - 可复现实验 = 参数进配置 + 记录三元组:随机种子、数据版本哈希、Prompt/模型配置都写进配置文件或记录,实验日志留下「数据 / Prompt / 模型」三元组哈希,任何一次实验都能凭记录还原。
学习路径
- 读 1.4:先用 cProfile / py-spy 拿到火焰图再说
- 跑剖析示例,按 80/20 定位热点,避免优化不重要的 5%
- 用 tracemalloc 查一次内存峰值
- 对 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
- 类型标注 + mypy / pyright:动态语言的类型标注不会在运行时生效,但能在 CI 里拦掉大量低级错误,更是现代 IDE 自动补全与重构的前提。代价是几天的学习,收益是长期的。
- Pydantic 校验边界:所有来自外部(用户输入、LLM 输出、API 响应、数据库反序列化)的数据都必须在边界处校验。LLM 的输出本质是「不可信输入」,这是新手最常忽略的一点。
- 结构化日志而非 print:用
logging或structlog,训练与推理都要能按 request_id / trace_id 串联全链路。日志要能直接进 ELK / Loki 查询,而不是靠 grep 堆文件。 - 异常分层的思路:区分「可重试」(限流、超时、网络抖动)与「不可重试」(鉴权失败、参数非法、schema 不匹配),只有前者才进重试逻辑。这个分类会在第 4 阶段的可靠性设计里被正式工程化。
__slots__与frozen:数据密集的对象(一个样本、一个检索结果)用slots=True能省 30–50% 内存;frozen=True避免意外修改导致的难查 bug。
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、模型输出越界 | 否 | — | 降级解析或转人工,修代码 |
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 | 饱和:再加大收益递减甚至倒挂 |
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 匹配到的是进程命令行里的模块路径——两者各指真实对象,都不是写错。- 包管理:新项目用
uv(2026 年事实上的快标准)或poetry;锁文件必须提交。绝不要在服务器上pip install一堆未锁版本的包——这是「上周还能跑,今天跑不了」的头号原因。 - 为什么代码放
src/:如果源码在仓库根目录,import hamauls_orion会在「当前工作目录」生效,导致本地能跑、安装后崩溃。src/布局强制你以「已安装包」的方式测试,能提前发现打包问题。 - 配置分层:默认值写代码里,环境差异用环境变量 / Profile 覆盖,密钥永不出现在仓库(用
.env+.gitignore,生产用密钥管理服务)。 - 测试三件套(AI 项目最容易漏后面两类):① 纯函数单测(pytest,毫秒级,必须占大多数);② LLM 输出断言——不比对全文,而是断言结构(能被 schema 解析)、关键字段存在、以及用 LLM-as-Judge 打分是否达标;③ 端到端 smoke test(跑通一条最短链路,验证接线正确),标记为
@pytest.mark.slow不进常规 CI。 - 可复现的四个抓手:固定随机种子(含 numpy / torch / python random 三处)、记录数据版本哈希、记录模型版本、把实验参数写进配置而不是散落在脚本里。
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']
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 项目一般不选 |
--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')
"
- 顺序永远是:粗粒度计时 → 函数级 profiler → 行级 → 底层(GPU/网络)。跳过前两步直接上高级工具,通常是在优化不重要的地方。
py-spy是生产环境最实用的工具:低开销、无需改代码、能附加到运行中的进程。当线上服务「卡住但没报错」时,py-spy dump能在 5 秒内告诉你它卡在哪一行。- 小心 profiler 带来的失真:cProfile 会给每次函数调用加开销,所以它夸大了「调用次数多的小函数」的耗时。对照
time.perf_counter()的手工计时来校准判断。 - 字符串拼接是隐藏热点:大循环里
s += x在 Python 里是 O(n²)。改成join(list)(空串 join)往往能快几十倍;同理,日志格式化要用log.info("%s", x)而不是log.info(f"{x}")——后者即使日志级别不输出也会先做字符串拼接。
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_memory | 2–5x |
time.perf_counter 粗定位到模块 → cProfile 找 Top 5 自耗时函数 → line_profiler 定位到行 → 才动手。GPU 场景还要配合 nvidia-smi dmon 看利用率,利用率低通常是数据供给瓶颈,而不是算得快。1.5 动手练习与自测(Python 工程)
- 在本机用 timeit 对比
+=与join处理 10 万个短字符串,报出倍数并解释复杂度差异。判据:倍数通常 ≥100x;+=每次重新分配整串,是 O(n²),join先算总长再一次性分配,是 O(n)。 - 用 Little 定律估算所需并发:若单条请求平均耗时 W=1.5s、目标吞吐 λ=40 QPS,需要多少并发?为什么还要再留余量?参考答案:约
40 × 1.5 = 60并发;因 P99 长尾常是 P50 的 5–20 倍,实际需 2–3 倍余量,否则尾延迟爆炸。 - 给一段在 async 函数里直接调用
requests.get与time.sleep的代码,指出问题与两种修法。判据:两者都会阻塞事件循环,使全部并发退化为串行;修法一:await asyncio.to_thread(...);修法二:换成httpx.AsyncClient/asyncio.sleep。 - 把 6 种典型异常分成「必须重试 / 绝不重试」两类,并说明退避策略。参考答案:重试=429 限流、超时、连接错误 / 5xx(指数退避 + 抖动,上限 30s,次数 2–4);不重试=401/403 鉴权、400 参数非法 / schema 不符(重试只浪费配额,应立刻失败并告警)。
- 为什么
uv.lock必须提交进 git?不提交会发生什么?判据:锁文件固定了每个依赖的精确版本与哈希,保证「今天能跑、下周还能跑」;不提交则不同机器 / 不同时间解析到不同版本,产生「薛定谔的可复现」。 - 线上 Python 服务「卡住但没报错」,写出三步定位命令。参考答案:①
py-spy dump --pid看所有线程调用栈(最快判断卡在哪一行);②ps aux | grep确认进程与 CPU;③strace -p看系统调用。若栈停在同步 I/O 或锁上,基本即锁定原因。-e trace=network
| 自测项 | 合格线 | 优秀线 |
|---|---|---|
| 类型标注与 Pydantic 边界校验 | 能在 CLI 项目里全量标注并通过 mypy | 对所有外部输入(含 LLM 输出)都有校验与降级 |
| 并发管线 | asyncio + Semaphore 跑通 1 万条不丢不重 | 含分类重试、断点续跑、P50/P95 延迟与 token 成本统计 |
| 可复现性 | uv.lock + 数据版本哈希进仓库 | 实验日志三元组(数据/Prompt/模型哈希)全记录 |
2. TypeScript:AI 应用的交付层
学习路径
- 读 2.1:弄清 SSE 与 WebSocket 的分工,别用一个全能的预期做错选型
- 跑内置 fetch + ReadableStream 的 SSE 消费骨架,打印增量 delta
- 实现 Last-Event-ID 断线续传;用 AbortController 触发一次停止生成
- 完成动手练习:实现一个最小流式聊天框(+状态机)
核心知识点详解
- 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。
学习路径
- 读 2.1 的状态机部分:理解每个消息必须在 pending/streaming/done/error 之一
- 跑骨架,把增量文本渲染进 UI,用节流避免每 token 一次重渲染
- 写错误重试:区别「可重试的 5xx」与「不可重试的 4xx」,做降级
- 对接 M1:为 M1 前端消费流式输出加上错误与停止态
核心知识点详解
- 每条消息都在一个显式状态机里:pending → streaming → done / error。别用布尔 flag 到处拼——状态机让「正在生成时又来一条」「停止后残留半句」这类竞态自然消失,也便于在 done 后禁用输入、在 error 后展示降级。
- 逐 token 渲染要节流:每 token 渲染一次会触发大量 DOM 重排。用
requestAnimationFrame/ 文本缓冲区把 30–60fps 的量合并提交,显示体验几乎不变但 CPU 大幅下降。 - 错误重试要区分可重试与不可重试:5xx / 网络断 → 可重试(指数退避);4xx(403 鉴权、400 非法参数)→ 不可重试,直接降级并提示用户。别把「重试」和无差别告警混为一谈。
- Markdown 增量渲染避免整块重渲:流式输出用增量 Markdown(如 mdast 增量 patch 或简易分块)逐块上屏,而不是每次全量
marked()整段重渲,否则长文档会明显卡顿。
学习路径
- 读 2.1 联调部分:前后端共享一份类型(响应 schema),别靠嘴对齐
- 配 Vite 构建,处理 CORS 与鉴权头的一个实际报错
- 加一行前端埋点,指标里带上 token 消耗与耗时
- 对接 M1:让前端能连上后端 SSE 网关完成端到端流式
核心知识点详解
- 前后端共享一份类型,别靠嘴对齐:把响应 / 请求 schema 抽成双方共同依赖的
.ts(或从 OpenAPI 生成),字段名、嵌套结构、可选性一处改多处同步,杜绝「后端改了前端还说 404」。 - CORS 与鉴权头是联调常见坑:跨域用
Access-Control-Allow-Origin(生产别用*+ 带凭据的组合);OPTIONS预检要正确处理。鉴权头(Authorization/ Cookie)在 fetch 里要显式带credentials并按同源策略放行。 - 前端埋点 = 可观测闭环的最后一环:每个请求带上 token 消耗、首 token 延迟、完整耗时,落到指标里。没有埋点的前端,排查线上 badcase 只能靠「重现场景」猜,效率极低。
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) }));
}
}
- 必须懂的概念:fetch + ReadableStream、SSE 分帧(空行分隔、
data:前缀、注释行心跳)、AbortController、乐观更新、虚拟列表(长对话性能)、以及「流式渲染时的 markdown 增量解析」。 - 推荐的栈:React + TypeScript + TanStack Query;服务端流式用 Next.js Route Handler 或独立 FastAPI。
- 不要重复造轮子:Vercel AI SDK 这类库已封装了流式与工具调用协议,先学会用,再学会它封了什么。但一定要手写一次原始 SSE 消费,否则出问题时你无法定位。
- 边界校验:前端所有外部数据(含模型输出)也要校验;后端已是不可信输入,前端同样——尤其是当你要把模型输出渲染成 HTML 时(XSS 风险),必须走 markdown 安全渲染而不是 innerHTML。
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 | 双向 | 需自己实现心跳与重连 | 实时协作、语音、多轮双向 | 协议更重,纯文本流多数不必 |
| 长轮询 | 拉取模拟 | 天然重连 | 老环境 / 受限网络兜底 | 延迟高、连接开销大,仅兜底 |
useChat 直接拿到「消息状态机 + 工具调用卡片 + 中断重试」。手写一次原始 SSE 消费仍然必要——线上出现「消息卡住」「重复 token」「工具结果丢失」时,你要能顺着协议逐帧核对,而不是只能重装依赖。GPT-5 / Gemini 3 / Claude 4.5 的流式工具调用都走了「先发 tool_call 增量、再发参数增量、最后发结果」的分阶段事件。2.2 动手练习与自测(TypeScript 交付层)
- 写一个最小 SSE 消费循环,遇到
\n\n分帧、跳过注释行心跳、识别[DONE]。判据:缓冲未闭合的尾帧、注释行以:开头跳过、data:前缀剥离后再 JSON.parse。 - 为什么生产里多用
fetch + ReadableStream而不是原生EventSource?参考答案:EventSource 只能发起 GET、不能带 Authorization 等自定义 header、不能发 POST body、错误重连能力有限;LLM 对话要 POST 且要鉴权,因此手写。 - 实现「停止生成」,并说明服务端要做什么。判据:前端用 AbortController 把 signal 传给 fetch;服务端需在连接断开 / 取消时终止推理(否则继续占用 GPU 生成无人消费的 token)。
- SSE 的断点续传需要客户端与服务端各做什么?判据:服务端每条事件带
id:;客户端重连时带Last-Event-ID头;服务端据此从该 id 之后续发,避免重复与丢失。 - 把模型输出渲染进页面有哪些安全与性能坑?判据:安全——XSS,禁止 innerHTML 直插,用安全 markdown 渲染(如 DOMPurify 或多层白名单);性能——避免每个 token 触发全量重渲染(用增量 markdown 解析 + 虚拟列表)。
| 自测项 | 合格线 | 优秀线 |
|---|---|---|
| SSE 消费 | 能正确按空行分帧、跳过心跳、识别 [DONE] | 含 Last-Event-ID 续传与指数退避重连 |
| 交互状态机 | loading / streaming / done / error 四态清晰 | 再加 stopped 与可重试,且服务端能响应取消 |
| 渲染安全与性能 | 模型输出走安全 markdown 渲染 | 增量解析 + 虚拟列表,长对话不卡 |
3. 数据工程与 SQL:被低估的胜负手
学习路径
- 读 3.1:先会写窗口函数(ROW_NUMBER / RANK / LAG)与 OVER 帧
- 跑内置 SQL,用 CTE 拆一个「先过滤再汇总」的查询
- 重点处理 JOIN 产生的重复:去重前先想清楚按哪个维度(主键/时间窗)
- 对接 M1/M2:用窗口函数从调用日志里构造评测集(分层抽样)
核心知识点详解
- 窗口函数 = 分组运算但保留每一行:
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 里都有代表且比例稳定,评测结果才可信。
学习路径
- 读 3.2:把一段 pandas for 循环改成 polars LazyFrame(向量化)
- 跑内置代码,对比 pandas 与 polars 在大表上的内存与耗时
- 按「数据质量四查」逐项核对一份脏数据,记录每类抓到的量
- 对接 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;
- 窗口函数的三个必会模式:① 排名(ROW_NUMBER / RANK / DENSE_RANK 的区别要清楚);② 累计与滑动(
ROWS BETWEEN ... AND ...,注意默认帧是 RANGE 而非 ROWS,这是个经典陷阱);③ 前后对比(LAG/LEAD,用于算留存、转化、变化率)。 - CTE 让复杂查询可读:把「筛选 → 聚合 → 排名 → 采样」拆成多段 CTE,比嵌套子查询清晰得多。现代查询优化器对 CTE 的优化已经很好,不必因为性能而拒绝使用。
- 分层抽样比随机抽样更重要:随机抽样会让高频意图淹没有效样本。按业务维度(意图 / 长度 / 语言 / 难度)分区后各取 N 条,才得到有代表性的评测集。这是数据工作的核心手艺。
- 评测集与训练集隔离:同一来源的样本绝不能横跨两边,否则所有指标都是幻觉。更严格的做法是按「用户 / 时间 / 文档」维度做隔离,而不只是按行随机切分。
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 → 排名与累计全部无意义 |
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/ 作为数据卡的一部分。
- polars 为什么快:Rust 实现、多核并行、惰性执行与查询优化(谓词下推、投影下推)、Apache Arrow 列式内存布局(对向量化与 cache 友好——这正好呼应第 2 阶段的存储层次)。
- 迁移的心智转变:从 pandas 的「索引 + 就地修改」变成 pl 的「表达式 + 不可变链式」。一旦适应,代码更短、更快、更少 bug。
- 数据血缘与版本:用 DVC 或简单的「目录 + 内容哈希」记录。训练要能回答「这份权重是哪个数据版本训出来的」。没有数据版本,你就无法复现任何实验。
- 数据卡(datasheet):记录数据来源、许可证、清洗步骤、已知偏差与适用边界。这在第 9 阶段的领域微调里是硬性交付物——它是「负责任的数据使用」的最低要求,也是面试里的加分项。
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 + LSH | Jaccard 相似度 ≥ 阈值(常 0.8)的近重复 | 需 sketch,内存可控,Web 语料常再删 20–50% |
| 指纹去重 | SimHash | 汉明距离 ≤ 3 的近重复 | 快,但对长文改写不敏感 |
| 语义去重 | embedding 聚类 + 阈值 | 语义相同但字面不同的样本 | 贵(要过模型),仅高质量语料用 |
scan_parquet 直接对列式文件做谓词下推。真实量级:Web 级语料清洗中,精确去重 + MinHash 近重复合计常能删掉 30–70% 的原始文本;Qwen3 / Llama 4 / DeepSeek-V4 这类模型的数据报告里,去重与质量过滤都是「决定最终能力」的前置环节,而不是收尾步骤。记住:模型能力的天花板,很大程度由数据管线决定——同一套训练代码,换一版更干净的数据,评测分数常能差出好几个点。3.3 动手练习与自测(数据工程与 SQL)
- 写一条 SQL,从样本表按「用户哈希」切成 train/valid/test,并附一条泄漏自检查询。判据:用
CASE WHEN user_id % 10 ...或哈希取模按用户切;自检用GROUP BY user_id HAVING COUNT(DISTINCT split) > 1必须为空。 - 解释
ROWS BETWEEN ...与RANGE BETWEEN ...的区别,并说明默认帧是什么。参考答案:ROWS 按物理行,RANGE 按排序「值」;默认帧是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,对并列值会一起纳入,是累计结果与直觉不符的常见原因。 - 为什么随机抽样构造评测集通常不可取?判据:高频意图会淹没有效样本,分布偏斜;应按意图 / 长度 / 语言 / 难度分层各取 N 条。
- 用 polars 写出「读 JSONL → 过滤 → 正则归一 → 精确去重」的 lazy 管线;说明为何比 pandas 快。判据:用
scan_ndjson + filter + with_columns + unique + collect;快在 Rust 多核并行 + 惰性谓词 / 投影下推 + Arrow 列式布局。 - 精确去重、MinHash、embedding 聚类分别解决什么、代价如何?判据:精确抓逐字节重复(最便宜);MinHash 抓高 Jaccard 近重复;embedding 抓语义重复但最贵。三者是递进的成本—收益关系,通常按此顺序叠加使用。
| 自测项 | 合格线 | 优秀线 |
|---|---|---|
| SQL 能力 | 能写窗口函数 + CTE 完成排名与分层抽样 | 能看懂执行计划并对上亿行查询做索引优化 |
| 防泄漏 | 按用户 / 时间隔离 train 与 test | 含可自动运行的泄漏自检 SQL 并进 CI |
| 数据管线 | polars lazy 管线 + 质量四查报告 | 三级去重 + 数据卡 + 可复现版本哈希 |
4. Git / Linux / Docker / CI:部署前的最后一公里
学习路径
- 读 4.1:搭分支策略(protect main + 短生命周期特性分支 + PR)
- 跑一次 rebase / cherry-pick,把 .gitignore 补全(含 .env 与权重)
- 验证大文件不进 git:权重用 Git LFS 或对象存储 + 版本清单
- 对接 M1:提交 uv.lock 与数据版本哈希,确认 CI 全绿
核心知识点详解
- 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单次提交。
学习路径
- 读 4.2:先会看进程与信号,分清 SIGTERM 可优雅退出、SIGKILL 直接杀
- 用 top / ss / df / du 在一台服务器上定位「慢/卡/满」三类问题
- 用 grep / awk / sed 从日志里抽出错误行与统计
- 对接 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统计分布。
学习路径
- 读 4.3:写一个多阶段 Dockerfile(构建态 vs 运行时分离)
- 跑内置示例,docker build 看层缓存复用,用 --layers 方式核对体积
- 写一条 CI:lint → test → build → push,缺一层就失败
- 对接 M1:docker compose up 一条命令在本机把后端起起来
核心知识点详解
- 多阶段构建:构建态与运行时分离:第一阶段装编译依赖 / 全量包(构建态),第二阶段只拷贝产物 + 最小运行依赖(运行时),镜像体积可从 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 项目的特殊要求
- 分支策略:
main保护 + 短生命周期特性分支 + PR 评审。AI 项目额外要注意:实验分支不要长期存在,实验参数进配置而不是散在代码里,否则三个月后没人能还原。 - 大文件与管理:模型权重、数据集绝不进 git。用 Git LFS(小规模)或对象存储 + 版本清单(推荐)。
.gitignore要提前写好*.pt、*.safetensors、data/、.env、wandb/。 - 密钥泄露是最常见的严重事故:
.env必须在第一次 commit 前进.gitignore。万一泄露,改密码不等于安全——必须轮换密钥并清理历史(git filter-repo / BFG)。 - Prompt 与配置也要进版本管理:Prompt 是代码的一部分。改 Prompt 要能 diff、能回滚、能追溯「哪次改动导致指标下降」。这是 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 msg | stash 不作备份,别当长期存储 |
| 清理未跟踪文件 | git clean -nd 先预览,再 -f | 防误删 data/ 与 checkpoint |
| 查看某行历史 | git log -L 10,20:file.py | 定位某个 bug 引入行极有用 |
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 # 防止重复启动
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
# 覆盖率门槛:不是为了数字好看,而是防止「新代码完全没测」
- 分层缓存的收益是巨大的:把「装依赖」放在「拷代码」之前,代码改动时不需要重装依赖,镜像构建时间从几分钟降到几秒。这是 Docker 优化里性价比最高的一条。
- 多阶段构建:builder 阶段装编译工具与依赖,runtime 阶段只拷贝产物。镜像体积能小 5–10 倍,也减少了攻击面。
- GPU 容器:需要装 NVIDIA Container Toolkit,运行时用
--gpus all,基础镜像换成nvidia/cuda:...-runtime-ubuntu22.04。注意要选runtime而不是devel(后者体积大得多,除非你要编译 CUDA 代码)。 - compose 一键起依赖:向量库、Redis、Postgres 用 compose 起在本地,让「
docker compose up就能开发」成为团队的默认体验。
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 | 运行推理的推荐基础镜像 |
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)
- 服务「起来了但外部访问不到」,写出你的排查清单与判据。参考答案:
ss -ltnp | grep看是否监听及绑定地址(0.0.0.0 vs 127.0.0.1)→ps aux看进程是否退出 →free -h / df -h看内存磁盘 →tail -F/journalctl -u看日志 → 再查防火墙与依赖连通性。 - 用
git bisect在 128 个提交里定位一个回归,最多需要多少次验证?判据:约 log2(128)=7 次;配合git bisect run <脚本>可自动二分(脚本退出码非 0 判为「坏」)。 - 把「装依赖」放在「拷代码」之前为什么能显著加快构建?参考答案:Docker 按层缓存,代码改动只影响后面的层;若顺序反了,任何代码改动都会使依赖层缓存失效、重装全部依赖,构建从几秒变回几分钟。
- GPU 容器要做什么额外配置?基础镜像怎么选?判据:宿主装 NVIDIA Container Toolkit,运行时
--gpus all;基础镜像选nvidia/cuda:...-runtime(约 2–3 GB)而非-devel(约 6–9 GB),后者仅编译 CUDA 时才需要。 - 进程「活着但不可用」有哪些典型成因?判据:监听地址错(127.0.0.1)、事件循环被同步调用阻塞、显存被占满、磁盘写满、依赖未就绪;对策是健康检查真正打业务路径,并区分 liveness 与 readiness。
| 自测项 | 合格线 | 优秀线 |
|---|---|---|
| Git 工作流 | 分支 + PR + pre-commit 能拦住常见问题 | 会用 bisect / worktree / filter-repo 处理复杂场景 |
| Linux 排障 | 能按固定顺序查到进程 / 端口 / 资源 / 日志 | 会用 vmstat / iostat 区分 CPU、内存、磁盘瓶颈 |
| 容器与 CI | 多阶段构建 + compose 一键起依赖 | 镜像瘦身到 slim 级、GPU 用 runtime 基础镜像、CI 全绿 |
项目里程碑
从第 1 阶段起就建立 hamauls-orion/ 单体仓库:uv 管依赖、src 布局、类型标注、pytest、GitLab CI、Dockerfile;并写出第一个真实组件——并发调用模型 API 的评测/标注管线(限流 + 指数退避 + 断点续跑)。
hamauls-orion/monorepo:src/hamauls_orion/{core,data,models,serving,evals}五个包 +configs/+tests/+scripts/uv.lock已提交、ruff+mypy+pytest在 CI 中全绿hamauls_orion.data.pipeline:asyncio 并发调用模型 API,支持 Semaphore 限流、重试分类、JSONL 断点续跑、P50/P95 延迟与 token 成本统计- 多阶段
Dockerfile,docker compose up一条命令起服务
git clone && docker compose up 能跑通;CI 全绿;并发管线跑 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:可运行的并发评测 CLItests/:限流、重试分类、断点续跑三组的单元 / 集成测试- 一份运行报告(样例大小、延迟分布、成本、错误分类)
不做评测集打分逻辑(那是数据集清洗工具与 M6 的职责);不做 GPU 并行推理。
- 输出一份固定版本的清洗数据 + 数据版本哈希,同一次输入在任意机器重跑得出完全一致的版本号
- 生成清洗报告:每步抓了多少、误伤多少、最终保留多少
- 产出一张数据卡(来源、许可、清洗步骤、已知偏差)
- 用 polars 的
LazyFrame处理,避免for循环逐行操作 - 精确去重 → 长度过滤 → 语言过滤,每一步都记录数量并说明依据
- 必做「数据质量四查」:完整性 / 一致性 / 唯一性 / 时效性
- 使用 SQL 完成至少一次去重或分组统计(如窗口函数挑「每组最新一条」)
scripts/data/clean.py+tests/test_clean.py- 清洗报告(MD)+ 数据卡(datasheet)
- 版本化后的清洗数据文件(含内容哈希清单)
不做模型评测打分;不做模糊 / 近重复去重的超量工程,先保证精确去重与质量四查到位。
- 端到端流式:后端逐 token 推送,前端逐字渲染,停顿不卡顿
- 用户可点击「停止生成」,后端真正取消模型调用(不烧 token),前端进入 correct 状态
docker compose up一条命令在本机整体启动
- 后端
FastAPI以 SSE 流式返回,支持心跳保活与正确的连接中断处理 - 前端每条消息处于显式状态机
pending / streaming / done / error - 区分可重试(5xx / 断网)与不可重试(4xx)错误,做降级提示而非崩溃
- 后端在做模型调用前/后用
Pydantic校验入参与输出,前端用共享类型对接
services/api+services/web源码,docker-compose.yml- 前端导出的构建产物与本地联调说明
- 一条「建立连接 → 流式 → 停止 → 错误降级」的最小演示录屏或截图
不做多用户会话 / 鉴权 / 持久化;不做 Markdown 增量渲染的极致性能优化。
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 等业务模块(留给后续阶段)。
常见误区
- 把 notebook 当工程:没有类型标注、没有日志、没有测试,变量随手改名,三个月后自己都读不懂。
- 在 async 代码里调用同步阻塞函数(requests / time.sleep / 某些 SDK),以为「写了 async 就并发了」,实际整个事件循环被卡死。
- 用 `except Exception` 一把抓,把真正的 bug 也吞掉了,问题在生产环境才暴露。
- 不设并发上限,把对端限流打满后被封,或者把本地内存打爆。
- 把所有密钥、IP、模型名硬编码进代码,换环境要改代码而不是改配置;或者把 `.env` 提交进了仓库。
- 跳过数据质量直接调模型,然后花几周怀疑是模型不行。
- 日志用 f-string 拼接,在内层循环里付出了大量无谓的格式化开销。
- 只学 Python 完全不懂前端与 Docker,导致永远无法独立交付一个完整产品。
- 把模型权重与数据集塞进 git,仓库膨胀到无法克隆。
- 没有配置分层,实验参数散落在脚本里,无法复现任何一次实验。
面试高频问题速答
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 .`),能提前发现打包问题。这在需要发布内部包或部署到容器的项目里是必须的。