🏠 总目录📚 本教程 01 · 第一天:两小时上线
📑 本页目录(点开跳转)

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: {"teJSON.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 分钟训练你的第一个模型 连模型都还没训过就先去那边。本板块假设「模型已经好了」

✅ 检查点

  1. 「算不算上线」的验收动作是什么?为什么内网穿透不算?
  2. 密钥必须从环境变量读——除了安全,更现实的理由是什么?
  3. StreamingResponse 解决的是什么形状的需求?换成 Go 要多做哪一件事?
  4. 为什么一开始就用 SSE 分帧、而不吐裸文本?前端又为什么不用 EventSource
  5. {stream: true} 少了会怎样?为什么英文测试发现不了?frames.pop() 又在防什么?
  6. 为什么 AI 应用默认不该上 Serverless?挑平台的五条判据是什么?
  7. --host 0.0.0.0--port $PORT 少了任何一个,故障长什么样?为什么难查?
👀 答案
  1. 合上笔记本、用手机流量打开它,还能用才算。内网穿透靠你本机那个进程,合盖就没了。
  2. 平台给你的注入口只有环境变量这一个。写死等于每换一次 Key 都要重新提交+重新部署,而且代码从此不能公开。
  3. 能分批往外写、且连接保持打开」。Go 要额外手动 Flusher.Flush()ResponseWriter 默认带缓冲)。
  4. 迟早要往回送错误、用量、工具状态这类「不是正文」的东西,先分帧,以后加东西不用重写前端EventSource 只能 GET,问题得塞进 URL:长文本超限、换行要转义、还进浏览器历史。⭐ 也正因为前端是 fetch 手动解析,帧类型才放进 JSON 负载的 type 字段而不是 SSE 的 event: 行——event: 行只对 EventSource 有意义,放进负载则一行 JSON.parse 拿到全部信息。
  5. 网络分片会把一个 UTF-8 汉字(3 字节)切两半,蹦出 英文是单字节,怎么切都不坏,所以英文测试全绿。frames.pop() 防半个帧——read() 可能只拿到 data: {"te
  6. 执行时限(长回答跑一分钟很正常)、响应整体缓冲让流式直接失效、冷启动叠在首 token 上且连接池留不住。五条:常驻进程 / 超时 ≥ 5 分钟 / 不缓冲响应体 / 能设环境变量 / 自带 HTTPS
  7. 都是「启动成功、外面连不上、日志里干干净净」。难在没有报错——你会一直翻应用代码,问题却在监听地址和端口。

🛑 可以停在这里

走神救援

交付物是一个别人能打开的网址,验收只有一条:合上笔记本、用手机流量打开它——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

打卡记录保存在你的浏览器里,首页能看到总进度