Files
2026-09-22 15:42:25 +08:00

268 lines
15 KiB
Markdown
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 阶段一 · 小点 8:软件工程与基础设施
> 所属:阶段一 前置核心基础(必备打底)
> 定位:Agent 不是"只要模型会答就行"的玩具,它是要落地、要被人 review、要跑几个月不出乱的工程。这一节补上 Git、API、容器、数据库这四根"工程地基"。
## 精简大纲
1. 版本控制:Git 进阶(rebase / cherry-pick)、分支流规范
2. API 开发:FastAPI、RESTful、SSE 流式、WebSocket
3. 容器化:Docker、Dockerfile 分层优化、Docker Compose
4. 数据库基础:PostgreSQL / MySQL / Redis / MongoDB
## 学习内容详情
### 1. 版本控制(Git 进阶)
#### 1.1 merge 与 rebase 的区别
```mermaid
graph LR
A[功能分支开发完] --> B{合并方式}
B -->|merge| C[产生一个新的合并提交, 历史有分叉]
B -->|rebase| D[把提交搬到主分支顶端, 历史线性更清爽]
```
- **rebase(变基)**:把当前分支提交"搬到"目标分支顶端,保持线性历史;merge 产生分叉。
- **cherry-pick**:摘取某一个 commit 到当前分支——线上 bug 修在 dev 后,只把修复提交摘给 prod,不把一堆无关提交带上。
```bash
# cherry-pick 典型场景
git checkout prod # 切到生产分支
git cherry-pick <commit-hash> # 只把某个修复提交应用过来
git push origin prod
```
- **分支流(dev / test / prod)**:环境与分支一一对应;**分支保护规则(禁直推 prod)**是协作底线。
```
开发流程:
feature/x ──merge──▶ dev ──实测──▶ test ──发布──▶ prod
你自己 CI/CD 自动 (受保护, 禁直推)
```
### 2. FastAPI API 开发
#### 2.1 为什么 Agent 服务要用 FastAPI
```mermaid
graph LR
A[Agent 服务] --> B[POST /chat 普通问答]
A --> C[GET /tasks/id 查任务状态]
A --> D[GET /chat/stream SSE 流式]
A --> E[WS /realtime WebSocket 双向]
B --> F[RESTful 资源风格]
D --> G[单向输出·逐token]
E --> H[双向·语音/协作编辑]
```
- **RESTful**:以资源为中心的接口风格;对照 Agent 服务:POST /chat、GET /tasks/{id}。
- **路由**:按资源分文件组织,别全堆在 main.py。
- **依赖注入(DI)**:`Depends` 机制,全局共享资源(LLM client / 存储连接),测试时可替换成 mock。
- **SSE vs WebSocket 选型**:单向输出(Agent 逐 token 回答)用 **SSE**;双向实时交互(语音 / 协作编辑)才用 **WebSocket**。绝大多数 Agent 场景 SSE 就够。
> 🔬 **深度理解 · SSE 与 WebSocket(流式传输选型)**:
> 1. **本质**:SSE(Server-Sent Events)是"服务端 → 客户端"的单向文本事件流,基于 HTTP 长连接;WebSocket 是"双向全双工"的长连接帧协议,两者满足不同需求。
> 2. **机制**:SSE 复用 HTTP,服务端持续写 `data: ...\n\n` 事件,天然易解析、支持自动重连、无跨域握手改造成本;WebSocket 需先握手升级(http→ws)再双向收发文本/二进制帧,连接更"重"但能力更强。
> 3. **为什么重要**:Agent 逐 token 打字机输出是"单向推送给用户",SSE 恰好够用且实现最简;只有语音实时对话、协作编辑等需要"双向实时"才上 WebSocket。选错会白白增加复杂度。
> 4. **易错点**:SSE 是单向的(客户端不能跟服务器并行上行对流),需要双向就别硬套 SSE;WebSocket 要做心跳保活与断线重连,生产成本更高。
```python
# main.py —— 一个带 SSE 流式的 Agent 接口
import asyncio
import json
from fastapi import FastAPI, Depends
from fastapi.responses import StreamingResponse
app = FastAPI(title="Agent 服务")
# 依赖注入: 全局共享资源(例: LLM client), 可在测试时替换成 mock
def get_llm_client():
# 返回一个模型客户端(这里简化)
return {"model": "gpt-4o"}
@app.post("/chat")
async def chat(payload: dict, client: dict = Depends(get_llm_client)):
# RESTful: 资源是"聊天", 方法是 POST
return {"reply": "这是普通(非流式)回复"}
@app.get("/chat/stream")
async def stream_chat(llm=Depends(get_llm_client)):
"""SSE 流式接口: 逐 token 推给前端打字机渲染"""
async def event_stream():
for word in ["你好", ",", "我是", "AI", "助手"]:
yield f"data: {json.dumps({'token': word}, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.1) # 模拟模型逐字生成
yield "data: [DONE]\n\n" # 结束标记
return StreamingResponse(event_stream(), media_type="text/event-stream")
```
- **响应模型**:用 `response_model=ChatResponse` 约束输出结构,自动生成 swagger 文档;请求非法自动返回 422。
```python
from pydantic import BaseModel
class ChatResponse(BaseModel):
reply: str
used_tokens: int
@app.post("/chat2", response_model=ChatResponse)
async def chat2(payload: dict):
return ChatResponse(reply="结构化响应", used_tokens=123)
```
- **运行**:`uvicorn main:app --reload --port 8000`。
### 3. 容器化(Docker / Compose)
#### 3.1 镜像分层:把"装依赖"放"拷代码"之前
```mermaid
graph LR
A[FROM python:3.12] --> B[COPY requirements 先拷贝依赖清单]
B --> C[RUN pip install 安装依赖 → 形成缓存层]
C --> D[COPY . 最后才拷源码]
D --> E[改代码时常命中缓存, 不用重装依赖 → 更小更快]
```
- **Dockerfile 多阶段构建**:先装依赖(命中缓存层)再拷源码,镜像更小。
- **镜像分层**:把"装依赖"放"拷代码"之前,改代码时命中缓存不必重装依赖。
- **docker-compose.yml**:本地一键拉起"应用 + Redis + 向量库"整套环境。
- ⚠️ 不用 `docker commit` 造镜像(不可复现),一律用 Dockerfile / Compose 声明式定义。
> 🔬 **深度理解 · Docker 镜像分层与构建缓存**:
> 1. **本质**:镜像由若干只读层叠加而成,每条 `RUN/COPY/ADD` 指令生成一层(含该层的内容 diff 与元数据);构建时若某层输入未变,直接复用缓存而不重算。
> 2. **机制**:Docker 以"指令内容 + 当前父层镜像内容"作为缓存键。先 `COPY requirements.txt` + `RUN pip install`,再 `COPY .`——这样改业务代码时依赖层缓存命中、装依赖被跳过,迭代构建又快又省。
> 3. **为什么重要**:依赖安装往往最耗时(网络拉取 / 编译),把这层前移可让"改代码→重建"的迭代秒级完成;层组织也直接影响镜像体积与传输成本。
> 4. **易错点**:若把 `COPY .` 放到前面,任何源码改动都会让后续所有层缓存失效、依赖被迫重装;`docker commit` 生成的是基于运行态的黑盒镜像、不可复现,故生产一律用 Dockerfile 声明式构建。
```dockerfile
# Dockerfile —— 多阶段构建, 镜像更小更安全
FROM python:3.12-slim AS base
WORKDIR /app
# ① 先拷依赖清单并安装 → 命中缓存
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ② 最后才拷源码 → 改代码不动前面的缓存层
COPY . .
# 非流式/流式都从这里启
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
```yaml
# docker-compose.yml —— 一键拉起整套依赖
# version: "3.9" 新版 Compose 已弃用/忽略该字段, 省略即可, 此处仅注释说明
services:
app:
build: .
ports: ["8000:8000"]
environment:
REDIS_URL: redis://redis:6379
VECTOR_HOST: qdrant:6333
depends_on: # 等 redis/向量库起来再启动应用
- redis
redis:
image: redis:7
qdrant:
image: qdrant/qdrant # 向量数据库示例
```
### 4. 数据库基础
#### 4.1 选型速查
| 场景 | 数据库 | 关键点 |
|-|-|-|
| 结构化业务数据 / 任务元数据 | PostgreSQL / MySQL | SQL、表设计、事务 |
| 会话缓存 / 临时状态 | Redis | 内存 + TTL 过期 |
| 非结构化记忆 / 对话历史 | MongoDB | 文档存储、灵活 schema |
> ⏳ **短期可不深究**:MongoDB 的文档存储、灵活 schema、聚合管道等细节属于"非结构化记忆"这条补充线的进阶内容,第一遍只需记住"结构化业务用 SQL 库、临时缓存用 Redis、对话/记忆可用文档库"的分工即可,真接 MongoDB 时再深学。
```python
# Redis TTL: 会话 30 分钟无活动自动过期, 清理僵尸会话
import redis
r = redis.Redis(host="localhost", port=6379)
r.setex("session:abc123", timeout=1800, value="会话数据")
# ↑ setex: 设一个 1800 秒(30分钟)后自动过期的 key
print(r.get("session:abc123")) # 存在期内能读到
# 30 分钟后: r.get(...) 返回 None, 已被自动清理
```
```python
# PostgreSQL 事务: "写任务+写明细"两张表必须包事务, 要么全成要么全回滚
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
engine = create_engine("postgresql://user:pwd@localhost/agentdb")
Session = sessionmaker(bind=engine)
db = Session()
try:
db.execute(text("INSERT INTO tasks(...) VALUES(...)")) # 1) 写主任务(SQLAlchemy 2.x 裸 SQL 须用 text() 包裹)
db.execute(text("INSERT INTO task_items(...) VALUES(...)")) # 2) 写明细
db.commit() # 两步都成功才一起提交
except Exception:
db.rollback() # 任一步失败, 全部回滚, 不留半截脏数据
raise
finally:
db.close()
```
> **一句话原则**:凡是涉及"要一起变才有效"的多条写入(如任务 + 明细、账户 + 流水),必须包事务。Redis 适合可重建的临时缓存,不适合存需要强一致的核心业务。
> 🔬 **深度理解 · 数据库事务(ACID 与原子性)**:
> 1. **本质**:事务把"多条要一起成功才有效"的写入打包成原子操作——要么全提交,要么全回滚,杜绝"只写了一半"的中间态脏数据。
> 2. **机制**:围绕 ACID(原子性 / 一致性 / 隔离性 / 持久性)。实现上靠日志与锁 / 多版本并发控制(MVCC):出错时按 undo 日志回滚已写部分;并发事务用隔离级别防止读到彼此的中间状态。
> 3. **为什么重要**:Agent 的"任务 + 明细"、"账户 + 流水"这类强关联多写,少了事务会出现半截数据、账实不符;这正是关系库相对内存缓存的核心价值。
> 4. **易错点**:写操作在 `commit` 前一旦中途异常必须 `rollback`(别忘了 finally 里关闭会话);隔离级别选错会引入脏读 / 不可重复读;事务别包过长的耗时调用,长时间持锁会拖垮并发吞吐。
## 本节自检
- [ ] 能用 FastAPI 实现一个带 SSE 流式的接口并跑通
- [ ] 能编写多阶段 Dockerfile 并 compose 拉起应用 + Redis + 向量库
- [ ] 会用 Redis TTL 与 PostgreSQL 事务
- [ ] 能说清 merge vs rebase、以及何时用 cherry-pick
## 本节配套思考题(快速入门的检验)
1. merge 和 rebase 各产生什么形态的提交历史?在多人协作的共享分支上你会更偏好哪种?
2. "逐 token 回答"用 SSE,"语音实时对话"用 WebSocket,判断依据是什么?
3. 为什么 Dockerfile 要"先拷依赖再拷源码"?顺序反了会损失什么?
4. 任务表和它对应的明细表为什么要统一包在一个事务里?
## 本节常见面试题(深度解析)
> 针对本节核心知识的面试高频点,配合"本质+机制"式理解,能让你答得既有深度又有广度。
### 面试题 1:merge 和 rebase 各产生什么形态的提交历史?在多人共享分支上你会更偏好哪种?
- **面试官想考察**:是否理解 Git 两种整合方式的语义差异与协作风险,而非只背名词。
- **专业作答(含深度)**:
1. **merge**:把另一分支并入当前,生成一个"合并提交",保留双方分叉点,历史呈网状、保留真实发生过的并行路径。
2. **rebase**:把当前分支的提交"搬到"目标分支顶端并重写这些提交(新的哈希),历史线性清爽,便于 review 与主分支快进。
3. **取舍**:rebase/GitLab merge 更整洁但**改写了提交哈希**,在已共享/已推送的分支上 rebase 会破坏他人历史引用,造成混乱;merge 更安全保守但历史分叉多。
4. **实践建议**:主分支(dev/test/prod)与多人共享分支上不要随意 rebase、用 merge 保护历史;个人功能分支(feature)在合入前可用 rebase 压平到最新主线以保持整洁。
- **加分亮点 / 深度追问**:可提"rebase 与 merge 时冲突解决的差异(rebase 逐个提交重放、冲突也要逐个解)",以及 `git pull --rebase` 与 `rerere`(自动记录冲突解法)等进阶技巧。
### 面试题 2:"逐 token 回答"用 SSE、"语音实时对话"用 WebSocket,判断依据是什么?
- **面试官想考察**:能否把"单向 vs 双向"这一本质应用到传输选型,而非背协议名。
- **专业作答(含深度)**:
1. **本质区分**:SSE 是服务端→客户端的单向事件流(基于 HTTP);WebSocket 是双向全双工长连接。
2. **判断依据**:先问"这个场景需不需要客户端在连接期主动上行?"——Agent 打字机只需服务端单向推送 → SSE 够用且简单;语音对话/协作编辑要双向实时收发 → WebSocket。
3. **成本对比**:SSE 复用 HTTP、易解析、自动重连、实现轻;WebSocket 要握手升级、心跳保活、断线重连,生产成本更高,只在确有双向需求时用。
4. **结论**:绝大多数 Agent 场景是"单向推 token",SSE 是性价比最优解。
- **加分亮点 / 深度追问**:可提"SSE 也能在一条长连接上携带多字段事件(结构化事件名 + data)",以及 WebSocket 上的二进制流(语音帧)为何比文本流更适合实时交互的细节。
### 面试题 3:为什么 Dockerfile 要先"拷依赖再拷源码"?把顺序反过来会损失什么?
- **面试官想考察**:是否理解镜像分层缓存机制,能否推导出"层顺序影响构建缓存命中"。
- **专业作答(含深度)**:
1. **分层缓存原理**:每层以"指令 + 父层内容"做缓存键,命中即复用。
2. **先装依赖**:依赖清单(requirements)变动频率远低于源码,先 `COPY requirements` + `RUN pip install` 形成稳定的依赖层;改源码只重建后面的层,秒级迭代。
3. **反序的损失**:若先 `COPY .`,任何一行源码改动都会让整个缓存失效,依赖被迫全部重装(最耗时部分白跑),构建极其缓慢。
4. **延伸**:这也说明应把"爱变的内容放后面、稳定内容放前面",并避免用 `docker commit` 这类不可复现的黑盒方式造镜像。
- **加分亮点 / 深度追问**:可提 `.dockerignore`(排除 node_modules/.git 等避免无效缓存失效)、多阶段构建(仅把运行时依赖带入最终镜像压缩体积)这两个进阶优化。