# 阶段一 · 小点 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 # 只把某个修复提交应用过来 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 等避免无效缓存失效)、多阶段构建(仅把运行时依赖带入最终镜像压缩体积)这两个进阶优化。