📑 本页目录(点开跳转)
01 · 第一天:两小时上线
⏱ 42 分钟 | ⭐ 核心 | 🔨 必须动手 | 交付物是一个别人能打开的网址
🎯 一句话
两小时之后你手上会有一个链接,发给朋友,他在自己手机上打开就能用。 这一版故意缺一大堆东西——缺什么、分别在第几章补,末尾那张表就是整个板块的路线图。
🧩 一、「上线」的验收标准只有一条
| 档位 | 谁能用 | 算上线吗 |
|---|---|---|
localhost:8000 |
只有你,只在这台机器上 | ❌ |
| 内网穿透(ngrok 那类) | 别人能开,但你一合笔记本就没了 | ❌ |
| 部署到平台 ⭐ | 你关机、睡觉、出门,它照样在 | ✅ |
⭐ 验收动作:合上笔记本,用手机流量打开那个链接。能用,才叫上线。
🧩 二、最短路径的四个选择
| 位置 | 选什么 | 为什么是这个形状 |
|---|---|---|
| 后端 | FastAPI,一个文件 | 要一个「能异步、能分批往外写响应」的 HTTP 框架。这两条是硬需求,框架名字 🗓️ 会变 |
| 前端 | 一个 HTML 文件,原生 JS | 现在要验证的是链路通不通,不是构建工具链。上 React 会让你多花 40 分钟调打包 |
| 模型 | 云 API | 自建推理是另一条路(全景导论 07 展开),第一天不走 |
| 部署 | 能跑常驻进程的平台 | ⭐ 不是 serverless,理由在第六节,这是最容易选错的一步 |
🧩 三、后端:先说形状,再看代码
① 密钥必须从环境变量读。 不只是安全洁癖——部署平台给你的注入口只有环境变量这一个。写进代码意味着每换一次 Key 都要重新提交、重新部署,而且这份代码从此不能公开。
② 需要一个「能分批往外写、且连接保持打开」的返回方式。 浏览器要边收边显示,就不能等模型生成完再 return。FastAPI 里这东西叫 StreamingResponse,喂它一个生成器,yield 一次它就往连接里写一次。
③ 往外写的东西要分帧。 你迟早要往回送「不是正文」的东西——错误、用量、工具状态。先分帧,以后加东西不用重写前端。用 SSE:每帧一行 data: {JSON},帧间空一行,帧的类型放在 JSON 负载的 type 字段里(delta / usage / error / done)。⭐ 类型不放进 SSE 的 event: 行,是因为前端用 fetch 手动解析(第四节说了为什么不用 EventSource),而 event: 行只对 EventSource 有意义——放进负载,一行 JSON.parse 就拿到全部信息。⭐ 全板块统一这个格式,05 / 10 都照它来。
# app.py —— 一个能上线的最小 AI 后端
import json, os, time
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse, FileResponse, JSONResponse
app = FastAPI()
API_KEY = os.environ.get("ANTHROPIC_API_KEY") # ⭐ 密钥从环境变量读
MODEL = os.environ.get("MODEL", "claude-sonnet-5") # 🗓️ 模型名也别写死
def stream_from_model(prompt):
"""一段一段产出文本。没配 Key 就用假模型,这个文件永远跑得起来。"""
if not API_KEY:
for ch in "(假模型)我收到了:" + prompt:
time.sleep(0.02)
yield ch
return
import anthropic # pip install anthropic
client = anthropic.Anthropic(api_key=API_KEY)
with client.messages.stream(model=MODEL, max_tokens=1024,
messages=[{"role": "user", "content": prompt}]) as s:
for piece in s.text_stream: # ⭐ 生成一点就往外吐一点
yield piece
@app.post("/api/chat")
async def chat(req: Request):
prompt = (await req.json()).get("prompt", "").strip()
if not prompt:
return JSONResponse({"error": "prompt 不能为空"}, status_code=400)
def sse(): # ⭐ 每帧一行 data:,帧间空一行
for piece in stream_from_model(prompt): # ⭐ 类型放负载里,不用 event: 行
yield "data: " + json.dumps({"type": "delta", "text": piece},
ensure_ascii=False) + "\n\n"
yield 'data: {"type":"done"}\n\n' # ⭐ 结束帧也是一帧正常 JSON
return StreamingResponse(sse(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"}) # ⭐ 请代理别缓冲
@app.get("/")
def index():
return FileResponse("index.html")
⚠️ 两个细节:没 Key 就走假模型——第一天最怕「装了半天环境卡在 401」,把「链路通不通」和「Key 对不对」拆开能省你一小时(import anthropic 也因此放在函数里);X-Accel-Buffering: no 是发给中间层的请求,Nginx 那类代理默认攒够一批再转发,那样流式就白做了。
🧩 四、前端:核心就是「读流 + 拼帧」
⚠️ 别用 EventSource。 它只能发 GET,问题就得塞进 URL——长文本超限、换行要转义、还进浏览器历史。用 fetch 拿到 response.body 自己读,也就多五行。
<!doctype html>
<meta charset="utf-8">
<textarea id="q" rows="3" style="width:100%"></textarea>
<button id="go">发送</button>
<div id="out" style="white-space:pre-wrap"></div>
<script>
const out = document.getElementById('out');
document.getElementById('go').onclick = async () => {
const prompt = document.getElementById('q').value.trim();
out.textContent = '';
const resp = await fetch('/api/chat', {method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({prompt})});
if (!resp.ok) { out.textContent = '出错了:HTTP ' + resp.status; return; }
const reader = resp.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}); // ⭐ stream:true 是关键
const frames = buf.split('\n\n');
buf = frames.pop(); // ⭐ 最后一段可能是半个帧,留下
for (const f of frames) {
if (!f.startsWith('data: ')) continue;
const frame = JSON.parse(f.slice(6)); // ⭐ 每帧都是合法 JSON,解析路径只有一条
if (frame.type === 'done') return; // ⭐ 正常收尾的标志
if (frame.type === 'delta') out.textContent += frame.text;
}
}
};
</script>
⭐ 那两行 ⭐ 各堵一个必然发生的 bug:少了 {stream: true},网络分片会把一个 UTF-8 汉字(3 字节)切成两半,屏幕上蹦出 �——英文是单字节,怎么切都不坏,所以英文测试全绿。少了 frames.pop(),一次 read() 拿到 data: {"te 就 JSON.parse 必崩——最后一段永远留在缓冲区等下一批。
🧩 五、跑起来
pip install fastapi uvicorn
uvicorn app:app --reload --port 8000
两个文件放同一个目录,浏览器开 http://127.0.0.1:8000。验收三条:① 字一个一个蹦出来,不是憋一下全出现;② 中文不乱码;③ 输入框留空点发送,显示 HTTP 400 而不是白屏。
🧩 六、放到公网上 🗓️
🗓️ 这一节涉及外部平台,我没有实跑。 平台名字、免费额度、控制台按钮都会变, 所以下面给的是挑平台的判据和必然踩的坑,不是某家平台的点击路径。
⚠️ 为什么不选 Serverless。「函数计算 / 边缘函数」是这几年的默认答案,对 AI 应用却默认是错的:单次执行有时限(常见几十秒),而一次长回答跑一分钟很正常;响应常被整体缓冲后返回,💀 流式直接失效——本地逐字,线上憋 40 秒一次性出现;再加上冷启动叠在首 token 上、进程不常驻留不住连接池。
⭐ 你要找的是「能跑一个常驻容器」的平台,判据五条:常驻进程、单请求超时 ≥ 5 分钟、不缓冲响应体、能设环境变量、自带 HTTPS 域名。🗓️ 满足这五条的平台每年都在换名字,拿这五条去文档里搜。
要补的东西:一个 requirements.txt(写死版本号),加一行启动命令 uvicorn app:app --host 0.0.0.0 --port $PORT。⚠️ 这行里两个坑几乎人人踩:--host 默认只监听 127.0.0.1,即只接受容器自己发起的请求;$PORT 是平台随机分配再用环境变量告诉你的,写死 8000 一样接不上。两者症状一模一样——「启动成功、外面连不上、日志里干干净净」,难就难在没有报错。
密钥去平台后台的环境变量里设——这就是第三节那个设计的回报:代码一个字不用改。
🧩 七、⭐ 这一版缺什么(= 整个板块的路线图)
这张表比前面所有代码都重要。
⚠️ 这十七行按「会先咬到你」排序,不按章号排 ——所以「在哪补」那一列的章号是跳着的,这是故意的。
| # | 缺什么 | 现在的后果 | 在哪补 |
|---|---|---|---|
| 1 | ⭐ 没有限流和配额 | 一个脚本十分钟刷爆你的额度,你事后才从账单知道 | 12 章 限流与成本护栏 |
| 2 | ⭐ 密钥硬编码、没分环境 | 泄露了只能全站换,而泄露的 key 是别人拿你的钱调模型 | 13 章 配置与密钥 |
| 3 | ⭐⭐ 跨域、CSRF、XSS 全没防 | 别人的页面能替你的用户发请求;模型输出直接进页面就是一条注入链 | 08b 同源策略与 CORS · 08c CSRF 与 XSS |
| 4 | ⭐ 没有日志、没有指标 | 用户说「刚才出错了」,你查无可查 | 15 章 可观测性 |
| 5 | ⭐ 重试会重复扣费、重复生成 | 网络一抖客户端重发,账单上就是两笔 | 03c 幂等与条件请求 |
| 6 | 调模型没超时、没重试、没缓存 | 上游抖一下,用户看到 500 | 04 章 调用层 |
| 7 | ⭐ 接口只有一条 POST /chat |
接口一旦发出去就改不动了 —— 越晚定形状越贵 | 03b 接口的形状 |
| 8 | 流式只是「能用」 | 用户关页面你还在烧 token;出错了收不回已发的字 | 05 / 10 章 |
| 9 | 没有数据库 | 刷新一下,对话全没了 | 06 章 关系数据库 |
| 10 | 没有登录 | 没法区分「谁的对话」 | 08 章 · 08d OAuth2 与 OIDC |
| 11 | 一行测试都没有 | 改一处不知道弄坏了别处,而模型输出没法直接断言 | 15b 怎么测不确定的系统 |
| 12 | 改一行要手动重部署 | 越怕出事越不敢发版 | 14 章 容器化与部署 |
| 13 | 界面简陋、出错没提示 | 能用但不像个产品 | 09 章 最小可用前端 |
| 14 | 慢任务占着请求 | 一个长任务卡住,别人全在排队 | 11 章 长任务与队列 |
| 15 | 不能基于自己的资料回答 | 只能聊模型自己知道的东西 | 07 章 向量检索落地 |
| 16 | 传不了文件 | 上一行的「自己的资料」根本进不来 | 07b 文件上传与对象存储 |
| 17 | 列不出「我的历史对话」 | 第二个功能就卡住;⚠️ 硬翻页还会重复和漏项 | 06b 列表接口与分页 |
⭐ 别急着回头补,但要知道顺序。 排序的判据是「不补的代价多快、多不可逆」: 前几行都是你会在事后才发现、而且没法撤销的(钱已经烧了、key 已经泄了、别人的页面已经替你的用户发过请求了、现场已经没了), 后面几行是当场就能看见、也能补救的(用户看到 500、刷新丢了对话、界面丑)。 ⭐ 所以没有日志(第 4 行)比没有登录(第 10 行)先要命——出了事你连发生了什么都不知道, 而「分不清谁的对话」至少是个你自己看得见的问题。 ⭐⭐ 第 7 行「接口只有一条
POST /chat」值得单独说:它当场不疼,但不可逆性最高 —— 数据库改了可以迁移、界面丑了可以重写,而接口一旦有别人在用,改它就要别人配合。
💡 带字母后缀的行(03b / 03c / 06b / 07b / 08b / 08c / 15b)补的是「这个零件本来的规矩」, 主编号的章补的是「它被 AI 那四件事咬到的那一面」。⭐ 赶时间可以先跳过后缀章把主干跑通。
🔄 换个栈怎么对应
| 概念 | FastAPI | Node(Express / Hono) | Go |
|---|---|---|---|
| 分批往外写响应 | StreamingResponse(生成器) |
多次 res.write() / 返回 ReadableStream |
w.Write() + Flusher.Flush() ⭐ |
| 读环境变量 | os.environ.get |
process.env |
os.Getenv |
| 监听所有网卡 | --host 0.0.0.0 |
app.listen(port, '0.0.0.0') |
ListenAndServe(":"+port, h) |
⭐ Go 的 ResponseWriter 默认带缓冲,不手动 Flush 就攒着不发——可见「分批往外写」在每个栈里都要显式表达,只是 FastAPI 把它藏进了名字里。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 智能体工程 01 · 10 分钟造出你的第一个 Agent | 同样「先跑通再问为什么」。那边是模型侧的最小闭环(模型+工具+循环),这边是产品侧的(后端+流式+公网) |
| 智能体工程 16c · 接进真实产品 | ⭐ 你刚写的流式只是能用级别。为什么必须流式、错误怎么在流里传、取消怎么穿透调用栈,都在那一章 |
| 全景导论 07 · 本地部署与开源生态 | 第二节那格「先用云 API」的另一条路——什么时候值得自己跑模型 |
| ML基础 01 · 10 分钟训练你的第一个模型 | 连模型都还没训过就先去那边。本板块假设「模型已经好了」 |
✅ 检查点
- 「算不算上线」的验收动作是什么?为什么内网穿透不算?
- 密钥必须从环境变量读——除了安全,更现实的理由是什么?
StreamingResponse解决的是什么形状的需求?换成 Go 要多做哪一件事?- 为什么一开始就用 SSE 分帧、而不吐裸文本?前端又为什么不用
EventSource? {stream: true}少了会怎样?为什么英文测试发现不了?frames.pop()又在防什么?- 为什么 AI 应用默认不该上 Serverless?挑平台的五条判据是什么?
--host 0.0.0.0和--port $PORT少了任何一个,故障长什么样?为什么难查?
👀 答案
- 合上笔记本、用手机流量打开它,还能用才算。内网穿透靠你本机那个进程,合盖就没了。
- 平台给你的注入口只有环境变量这一个。写死等于每换一次 Key 都要重新提交+重新部署,而且代码从此不能公开。
- 「能分批往外写、且连接保持打开」。Go 要额外手动
Flusher.Flush()(ResponseWriter默认带缓冲)。 - 迟早要往回送错误、用量、工具状态这类「不是正文」的东西,先分帧,以后加东西不用重写前端。
EventSource只能 GET,问题得塞进 URL:长文本超限、换行要转义、还进浏览器历史。⭐ 也正因为前端是fetch手动解析,帧类型才放进 JSON 负载的type字段而不是 SSE 的event:行——event:行只对EventSource有意义,放进负载则一行JSON.parse拿到全部信息。 - 网络分片会把一个 UTF-8 汉字(3 字节)切两半,蹦出
�;英文是单字节,怎么切都不坏,所以英文测试全绿。frames.pop()防半个帧——read()可能只拿到data: {"te。 - 执行时限(长回答跑一分钟很正常)、响应整体缓冲让流式直接失效、冷启动叠在首 token 上且连接池留不住。五条:常驻进程 / 超时 ≥ 5 分钟 / 不缓冲响应体 / 能设环境变量 / 自带 HTTPS。
- 都是「启动成功、外面连不上、日志里干干净净」。难在没有报错——你会一直翻应用代码,问题却在监听地址和端口。
🛑 可以停在这里
⚡ 走神救援
交付物是一个别人能打开的网址,验收只有一条:合上笔记本、用手机流量打开它——
localhost和内网穿透都不算,它们依赖你本机那个进程。后端三个形状:① 密钥读环境变量,不只是安全,更因为平台给你的注入口只有环境变量这一个;② 浏览器要边收边显示,就不能等生成完再return,需要「能分批往外写、且连接保持打开」的返回方式——FastAPI 里叫StreamingResponse,喂它一个生成器,yield一次它写一次,Go 里还要额外手动Flush();③ 一开始就用 SSE 分帧(每帧一行data: {JSON},帧间空一行,帧类型放在负载的type字段里——delta/usage/error/done),因为迟早要往回送错误、用量、工具状态;⭐ 类型不放 SSE 的event:行,是因为前端用fetch手动解析,event:行只对EventSource有意义,放进负载则一行JSON.parse拿到全部信息(全板块统一这个格式)。两个细节:没 Key 就走假模型(把「链路通不通」和「Key 对不对」拆开,省一小时)、X-Accel-Buffering: no请代理别缓冲。前端别用EventSource(只能 GET);两行救命代码:decode(v, {stream:true})防中文被切两半(汉字 3 字节,英文测试永远发现不了)、buf = frames.pop()防半个帧被JSON.parse炸掉。部署 🗓️:Serverless 默认是错的——执行时限、响应整体缓冲让流式直接失效、冷启动加连接池留不住;挑平台看五条(常驻进程 / 超时 ≥ 5 分钟 / 不缓冲 / 能设环境变量 / 自带 HTTPS)。--host 0.0.0.0和--port $PORT少哪个都是「启动成功、外面连不上、日志里干干净净」,难就难在没有报错。最后那张十七行的「缺什么」表就是整个板块的路线图,⚠️ 按「会先咬到你」排序而不是按章号,判据是「不补的代价多快、多不可逆」——前几行(限流配额 / 密钥 / 跨域与注入 / 日志 / 重试重复扣费)都是事后才发现且撤不回来的,所以 ⭐ 没有日志(第 4 行)比没有登录(第 10 行)先要命;⭐⭐ 而第 7 行「接口只有一条POST /chat」当场不疼、不可逆性却最高——数据库能迁移、界面能重写,接口一旦有别人在用,改它就要别人配合。💡 表里带字母后缀的行(03b/03c/06b/07b/08b/08c/15b)补的是「这个零件本来的规矩」,主编号的章补的是「它被 AI 那四件事咬到的那一面」,赶时间可以先跳过后缀章把主干跑通。
你现在可以把链接发给一个朋友了。
真的,现在就发。后面十几章都是在给这个链接补它现在缺的东西,而补东西的动力,来自有人真的在用它。
下一节 👉 02-一个AI产品的解剖.md