文章目录
-
- 一、前言:发动机造好了,但它还住在黑窗口里
- 二、概念对齐:三件事把"能跑"变成"能上线"
-
- [2.1 Web 外壳:给命令行套一层 HTTP 服务](#2.1 Web 外壳:给命令行套一层 HTTP 服务)
- [2.2 SSE 流式:把"打印报告"变成"打字机"](#2.2 SSE 流式:把"打印报告"变成"打字机")
- [2.3 Docker:把"我机器上能跑"变成"处处可跑"](#2.3 Docker:把"我机器上能跑"变成"处处可跑")
- [2.4 一个关键澄清:`_ensure_session` 才是这期真正的隐藏主角](#2.4 一个关键澄清:
_ensure_session才是这期真正的隐藏主角)
- [三、动手做:给 Alex 装一层 Web 外壳](#三、动手做:给 Alex 装一层 Web 外壳)
-
- [3.1 双入口:`main.py` 分发](#3.1 双入口:
__main__.py分发) - [3.2 `llm_call_stream`:流式调用](#3.2
llm_call_stream:流式调用) - [3.3 `stream_chat_with_tools`:流式工具循环](#3.3
stream_chat_with_tools:流式工具循环) - [3.4 `web.py`:FastAPI 六端点 + 内嵌前端](#3.4
web.py:FastAPI 六端点 + 内嵌前端) - [3.5 会话列表 + 续聊:事件溯源的直接产品](#3.5 会话列表 + 续聊:事件溯源的直接产品)
- [3.6 `GOAL_STATE` 跨请求:无状态 HTTP + 有状态 Agent](#3.6
GOAL_STATE跨请求:无状态 HTTP + 有状态 Agent) - [3.7 Docker 三件套](#3.7 Docker 三件套)
- [3.1 双入口:`main.py` 分发](#3.1 双入口:
- [四、跑起来:亲眼看 Alex 变成"打字机服务"](#四、跑起来:亲眼看 Alex 变成"打字机服务")
-
- [现象 1:打字机流式](#现象 1:打字机流式)
- [现象 2:工具实时播报](#现象 2:工具实时播报)
- [现象 3:会话列表 + 续聊](#现象 3:会话列表 + 续聊)
- [现象 4:目标跨请求存活](#现象 4:目标跨请求存活)
- [现象 5:Docker 交付](#现象 5:Docker 交付)
- 五、执行脚本(本期新增完整代码)
- 六、总结:十五期,把一台发动机装进了车
一、前言:发动机造好了,但它还住在黑窗口里
第 15 期,也是最后一期。
先回头看这一路。第 01 期,Alex 是个只会调 API 的"婴儿";到第 14 期,它已经攒齐了记忆、人设、技能库、工具、TodoList、子代理、团队、MCP、Hooks 安检、多模型路由、目标驱动 ------ 28 个工具,一头完整的 Agent。
但有个尴尬的事实我一直在回避:它一直住在命令行里。 每次用,都要开一个终端,敲 python -m ...,目标:... 或者一句句和它对答,盯着黑窗口。
这是台"发动机",不是台"能上路、能交付的车"。搞到第十五期才说破,有三件必须补的事:
- 命令行不是产品 。没人会天天开终端和一个黑窗口聊天,真正的形态是在浏览器里打开一个舒服的界面。
- 一次性整段吐回答,像在"打印报告" 。前 14 期所有调用都是
stream=False:等 LLM 把整段话算完,一次性print出来。用户盯着黑屏干等几秒,中间一点反馈都没有。ChatGPT 那种"一个字一个字蹦出来"的打字机,才是产品的标配。 - "我机器上能跑" ≠ "能交付"。依赖怎么装、路径怎么放、API Key 怎么进、记忆怎么持久化 ------ 把 Alex 交给别人,不能指望对方照着 README 手搓一遍。
一句话:前 14 期造的是"发动机",第 15 期要把它装进一辆能上路、能交付的车。
本文是系列第 15 期(最终期),做三件事,而且一行不改前 14 期的 Agent 能力,只加外壳和交付:
- Web UI :用 FastAPI 把 Alex 包成服务,内嵌一段单文件前端,浏览器打开
http://localhost:8000即用 - 流式响应(SSE):让它的每个字"边生成边到前端",工具调用也实时播报,把"打印报告"变成"打字机"
- Docker 部署:Dockerfile + docker-compose,一次构建、处处可跑
老规矩,本文结构:概念对齐 → 动手写代码 → 跑起来看效果 → 完整代码 → 总结。系列累积式迭代,最后一棒:step15 = step14 + 部署上线。

二、概念对齐:三件事把"能跑"变成"能上线"
2.1 Web 外壳:给命令行套一层 HTTP 服务
把前 14 期的纯逻辑(Python 函数)套一层 FastAPI,拆成几个 HTTP 端点。核心就是这条链:
浏览器输入 → fetch POST /chat → StreamingResponse → 逐块推回浏览器
FastAPI 只是"壳",内核还是那套 build_system_prompt() + 工具循环 + 记忆层。壳与核的边界,是这一期最重要的手感:换壳(Web/CLI/未来接 API 网关)不动核。
2.2 SSE 流式:把"打印报告"变成"打字机"
前 14 期,LLM 的答案是一次性返回的。要打字机体验,有两个选择:
| 方案 | 连接 | 方向 | 适用场景 | 复杂度 |
|---|---|---|---|---|
| SSE | 走普通 HTTP | 服务端 → 客户端单向下推 | 聊天:发一条,持续吐多个事件直到结束 | 低,fetch 流式读即可 |
| WebSocket | 独立长连接协议 | 双向 | 协同编辑、实时对战、双向推送 | 高,要维护长连接生命周期 |
聊天是典型的"单向流 + 每轮结束 ":客户端发一条消息,服务端持续吐 delta / tool_call / tool_result 多个事件,直到 done。SSE 只需要 HTTP,浏览器原生 fetch + getReader() 就能读 ------ 对"AI 打字机"这个需求,SSE 是最小够用方案,没必要上 WebSocket。
2.3 Docker:把"我机器上能跑"变成"处处可跑"
依赖装不对、路径放错、环境变量漏传 ------ 交付路上的脏活,交给 Docker 三件套:
- Dockerfile:分层缓存,依赖最稳放最前,改代码只重建最后一层
- docker-compose.yml :一键起服务,
env_file注入 API Key,volumes挂载memory/让记忆持久化 - .dockerignore :挡住
.env、__pycache__、.venv、memory/这些不该进镜像的东西
2.4 一个关键澄清:_ensure_session 才是这期真正的隐藏主角
Web 服务是无状态的 HTTP,而 Alex 是"有状态的 Agent"------ 它记着上下文、记着目标、记着账单。这两者怎么衔接?这期最容易被忽略的坑就是:FastAPI 进程内的会话表,服务一重启就没了。 解法是 _ensure_session():首次(或重启后)从 memory/raw_history.jsonl 把该会话的 user/assistant 回放进上下文,于是刷新页面、甚至重启服务,点了那条会话还能接着聊。这件事留在第三节细说。
三、动手做:给 Alex 装一层 Web 外壳
进入代码。所有代码在 code/step15_deploy/ 这个模块化包 里 ------ 相比前 14 期的单文件脚本,这一期第一次拆成了包,但内核逻辑一行没动,step15 = step14 + 部署上线。
3.1 双入口:__main__.py 分发
python
def main():
if "--cli" in sys.argv:
from .main import main as cli_main # 回命令行(第 14 期入口)
cli_main()
else:
from .web import run_web # 默认起 Web(本期核心)
run_web()
python -m step15_deploy 默认 Web,加 --cli 回命令行 ------ 两个入口共用同一套 build_system_prompt()、同一套 stream_chat_with_tools()、同一套工具和记忆层。
3.2 llm_call_stream:流式调用
路由层(第 13 期)新增一个流式版调用,和 llm_call 唯一的区别就是 stream=True:
python
# model_router.py:流式版 llm_call,其余(模型选择、成本记账、重试)完全复用
def llm_call_stream(messages, task="main", tools=None):
kwargs = _build_kwargs(messages, tools)
kwargs["stream"] = True
kwargs["stream_options"] = {"include_usage": True} # 流式也要拿到 token 用量
...
for delta in response:
yield delta, model_id
3.3 stream_chat_with_tools:流式工具循环
这是本次最容易翻车的地方。OpenAI 流式返回里,工具调用的参数是分片到达的 :一个 tool_call 的 id、name、arguments 会拆成多个 chunk 分开送过来。stream.py 用 setdefault(tc.index, {...}) 按 index 累积分片,攒齐再执行:
python
def stream_chat_with_tools(messages, session_id=None):
while True:
content_parts, tool_calls = [], {}
for delta, model_id in llm_call_stream(messages, task="main", tools=TOOLS):
if delta.content:
content_parts.append(delta.content)
yield {"event": "delta", "text": delta.content, "model": model_id}
if delta.tool_calls:
for tc in delta.tool_calls:
slot = tool_calls.setdefault(tc.index, {"id": "", "name": "", "arguments": ""})
if tc.id: slot["id"] = tc.id
if tc.function and tc.function.name: slot["name"] += tc.function.name
if tc.function and tc.function.arguments: slot["arguments"] += tc.function.arguments
content = "".join(content_parts).strip()
if tool_calls:
# 落盘 assistant.tool_calls,逐个执行工具,回灌,再循环
...
for slot in ordered:
yield {"event": "tool_call", "name": slot["name"], "arguments": slot["arguments"]}
fn_args, result = _run_one_tool(slot["id"], slot["name"], slot["arguments"])
yield {"event": "tool_result", "name": slot["name"], "result": result}
...
continue
# 无工具 → 落盘回答 → done
append_and_persist(messages, {"role": "assistant", "content": content}, session_id)
yield {"event": "done", "text": content}
return
事件协议就五种(加一个 [DONE] 结束哨兵):
{"event": "delta", "text": "...", "model": "deepseek-chat"}
{"event": "tool_call", "name": "...", "arguments": {...}}
{"event": "tool_result", "name": "...", "result": "..."}
{"event": "done", "text": "..."}
{"event": "error", "message": "..."}
3.4 web.py:FastAPI 六端点 + 内嵌前端
python
app = FastAPI(title="Alex · 第 15 期 Web UI", docs_url=None, redoc_url=None)
@app.get("/health") # 健康检查
@app.get("/goal") # 读当前目标契约(第 14 期伏笔的 Web 版验证点)
@app.get("/sessions") # 会话列表(左侧栏)
@app.get("/history/{session_id}") # 回放某会话并重建上下文
@app.get("/") # 内嵌前端(单文件 HTML)
@app.post("/chat") # 对话核心,返回 SSE
/chat 是心脏,它把 stream_chat_with_tools 的每个事件序列化成 SSE 的 data: 行:
python
@app.post("/chat")
async def chat(payload: dict):
message = (payload.get("message") or "").strip()
session_id = payload.get("session_id") or "web-default"
...
async def event_stream():
messages = _get_messages(session_id)
append_and_persist(messages, {"role": "user", "content": message}, session_id)
messages[:] = trim_history(messages) # 截断
if _ROUNDS[session_id] > COMPACT_THRESHOLD_ROUNDS:
messages[:] = compact_history(messages) # 压缩(复用记忆层)
try:
for ev in stream_chat_with_tools(messages, session_id):
yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n"
except Exception as e:
yield f"data: {json.dumps({'event': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"}) # 关缓冲,让 SSE 逐字到前端
前端呢?没有引入任何框架 ------ 是一段内嵌在 web.py 里的 HTML (INDEX_HTML),深色聊天界面,fetch('/chat') 拿到 res.body.getReader() + TextDecoder 逐块读流,handleEvent 按事件类型渲染文字/工具气泡。
3.5 会话列表 + 续聊:事件溯源的直接产品
前 07 期开始,Alex 每轮消息就"产生即落盘"到 memory/raw_history.jsonl(append-only,每条带 session 字段)。Web 层没有另建一张"会话表" ------ 直接扫这个文件、按 session 聚合就是会话列表,记忆层和 Web 层共用同一份真相。
python
def _list_sessions() -> list[dict]:
"""扫 raw_history.jsonl,按 session 聚合出会话列表(事件溯源,天然可回放)。"""
...
def _session_history(session_id) -> list[dict]:
"""读某会话的 user/assistant 消息,用于前端回放历史。"""
...
真正关键的兜底是 _ensure_session:
python
def _ensure_session(session_id):
if session_id in _SESSIONS: # 进程内已建过,直接复用
return _SESSIONS[session_id]
messages = [{"role": "system", "content": build_system_prompt()}]
for it in _session_history(session_id): # 重启后从落盘历史回放
messages.append({"role": it["role"], "content": it["content"]})
_SESSIONS[session_id] = messages
return messages
这一步解决了 Web 版最容易被忽略的坑:进程内 _SESSIONS 在服务重启后就没了,但 Alex 不能每次重启都失忆。 _ensure_session 从 raw_history.jsonl 把该会话的 user/assistant 回放进 messages,于是"刷新页面 / 重启服务后再点那条会话"依然能接着聊。生产版把这份进程内状态换成 Redis/数据库,衔接方式完全不变。
3.6 GOAL_STATE 跨请求:无状态 HTTP + 有状态 Agent
第 14 期埋的伏笔:"目标驱动的 Agent 挂进服务里,用户发一个模糊目标就下线了,控制循环怎么跨 HTTP 请求继续跑?"
答案是:目标契约 GOAL_STATE 是模块级全局变量,天然跨 HTTP 请求存活。 用户在 Web 里让 Alex 订了目标,刷网页、再发一个请求,GET /goal 读到的还是同一个契约。web.py 用 from . import goal 直接读这个全局状态 ------ 这就是"无状态 HTTP"承载"有状态 Agent"的衔接点。
3.7 Docker 三件套
dockerfile
# Dockerfile:分层缓存,依赖最稳放最前,改代码只重建最后一层
FROM python:3.11-slim
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY code/step15_deploy/ /app/code/step15_deploy/
COPY templates/ /app/templates/
COPY skills/ /app/skills/
COPY mcp_servers/ /app/mcp_servers/
WORKDIR /app/code
CMD ["python", "-m", "step15_deploy"]
yaml
# docker-compose.yml:一键起服务,env 注入 API Key,memory 挂卷持久化
services:
alex:
build: .
ports: ["8000:8000"]
env_file: [".env"]
volumes: ["./memory:/app/memory"]

四、跑起来:亲眼看 Alex 变成"打字机服务"
先在项目根目录配好 key,把服务起起来:
bash
cd code
python -m step15_deploy # 默认:Web 服务
# 浏览器打开 http://localhost:8000
现象 1:打字机流式
在浏览器里抛一个需要写一段代码或长回答的问题,观察文字是一个字一个字冒出来 ,而不是整块弹出。这是 delta 事件在起作用。
现象 2:工具实时播报
问一个会触发工具的问题(比如让它 查现在几点 或 set_goal 订目标),界面里「🔧 调用工具... ✅ 完成」的气泡和文字流交织出现 ------ 这就是 stream_chat_with_tools 把工具状态也当事件 yield 的效果。
现象 3:会话列表 + 续聊
左边「🗂 会话」栏列出所有历史会话(读 raw_history.jsonl 聚合)。随便点一条,该会话的 user/assistant 记录回放出来;然后刷新页面、甚至重启服务 ,再点那条,上下文从落盘历史重建,可以接着聊。/sessions + /history/{session_id} 把"事件溯源"变成了前端可见的会话列表。
现象 4:目标跨请求存活
在 Web 里用 目标: 前缀订一个目标,然后刷新页面,再点一下「🎯 目标」按钮 ------ 契约还在。这就证明了 GOAL_STATE 模块级全局变量跨 HTTP 请求幸存,第 14 期伏笔回收。
现象 5:Docker 交付
bash
cp .env.example .env # 填好 DEEPSEEK_API_KEY
docker compose up --build # 浏览器打开 http://localhost:8000
改几行 web.py,再 docker compose up --build,体会"依赖层缓存命中、只重建源码层"的分层缓存 ------ 这就是"交付"的体感。
五、执行脚本(本期新增完整代码)
完整可运行代码在仓库 code/step15_deploy/ 这个包(前 14 期能力全部在其中)。本期新增的核心 ------ web.py、stream.py、model_router.py(流式调用)、__main__.py(分发)------ 摘录如下:
python
#!/usr/bin/env python3
# ↑ step15_deploy/__main__.py(包入口:分发 Web / CLI)
def main():
if "--cli" in sys.argv:
from .main import main as cli_main # 第 14 期命令行入口
cli_main()
else:
from .web import run_web # 第 15 期默认:Web 服务
run_web()
python
# ↑ step15_deploy/web.py(核心:FastAPI + SSE + 内嵌前端)
app = FastAPI(title="Alex · 第 15 期 Web UI", docs_url=None, redoc_url=None)
_SESSIONS: dict[str, list[dict]] = {}
_ROUNDS: dict[str, int] = {}
def _list_sessions() -> list[dict]:
"""扫 raw_history.jsonl,按 session 聚合会话列表。"""
...
def _session_history(session_id) -> list[dict]:
"""读某会话的 user/assistant 消息,用于前端回放。"""
...
def _ensure_session(session_id):
"""取(或重建)进程内上下文。重启后从落盘历史回放,做到可续聊。"""
if session_id in _SESSIONS:
return _SESSIONS[session_id]
messages = [{"role": "system", "content": build_system_prompt()}]
for it in _session_history(session_id):
messages.append({"role": it["role"], "content": it["content"]})
_SESSIONS[session_id] = messages
return messages
@app.post("/chat")
async def chat(payload: dict):
message = (payload.get("message") or "").strip()
session_id = payload.get("session_id") or "web-default"
async def event_stream():
messages = _get_messages(session_id)
append_and_persist(messages, {"role": "user", "content": message}, session_id)
messages[:] = trim_history(messages)
if _ROUNDS[session_id] > COMPACT_THRESHOLD_ROUNDS:
messages[:] = compact_history(messages)
try:
for ev in stream_chat_with_tools(messages, session_id):
yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n"
except Exception as e:
yield f"data: {json.dumps({'event': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
python
# ↑ step15_deploy/stream.py(核心:流式工具循环,与非流式 chat_with_tools 孪生)
def stream_chat_with_tools(messages, session_id=None):
while True:
content_parts, tool_calls = [], {}
for delta, model_id in llm_call_stream(messages, task="main", tools=TOOLS):
if delta.content:
content_parts.append(delta.content)
yield {"event": "delta", "text": delta.content, "model": model_id}
if delta.tool_calls:
for tc in delta.tool_calls:
slot = tool_calls.setdefault(tc.index, {"id": "", "name": "", "arguments": ""})
if tc.id: slot["id"] = tc.id
if tc.function and tc.function.name: slot["name"] += tc.function.name
if tc.function and tc.function.arguments: slot["arguments"] += tc.function.arguments
content = "".join(content_parts).strip()
if tool_calls:
ordered = [tool_calls[i] for i in sorted(tool_calls)]
append_and_persist(messages, {"role": "assistant", "content": content,
"tool_calls": [...]}, session_id)
for slot in ordered:
yield {"event": "tool_call", "name": slot["name"], "arguments": slot["arguments"]}
fn_args, result = _run_one_tool(slot["id"], slot["name"], slot["arguments"])
yield {"event": "tool_result", "name": slot["name"], "result": result}
append_and_persist(messages, {"role": "tool",
"tool_call_id": slot["id"], "content": result}, session_id)
continue
append_and_persist(messages, {"role": "assistant", "content": content}, session_id)
yield {"event": "done", "text": content}
return
六、总结:十五期,把一台发动机装进了车
核心要点
- 壳与核分离:前 14 期的 Agent 能力一行未动,只是从"一个进程里的函数"变成"一个服务里的端点"。Web/CLI 双入口共用同一套内核
- SSE 是"打字机"的最小够用方案 :单向下推 + 每轮
done结束,普通 HTTP +fetch流式读即可,不必上 WebSocket - 流式工具循环的分片坑 :
tool_calls的id/name/arguments分片到达,用setdefault(tc.index, {...})按索引累积分片是"流式+工具"最容易翻车处 - 事件溯源一以贯之 :
/sessions聚合、/history回放、_ensure_session续聊,全靠前 07 期就开始攒的raw_history.jsonl------ Web 层不另起炉灶 - 无状态 HTTP + 有状态 Agent :
GOAL_STATE模块级全局变量跨请求存活,是第 14 期伏笔的 Web 版答案
一行代码记住本期
python
for ev in stream_chat_with_tools(messages, session_id):
yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n" # Agent 的每个字/动作,流式推给浏览器
十五期全栈串联(课程收官)
| 期 | 核心能力 | 一句话类比 |
|---|---|---|
| 01 | 认识 Agent + 开工 | 婴儿出生(能调用 API) |
| 02 | 连续对话(无记忆) | 金鱼记忆 |
| 03 | 短期记忆 history | 能记住上一句 |
| 04 | System Prompt 人设 | 有身份 |
| 05 | Tool Use 工具调用 | 有手 |
| 06 | Skills 按需加载 | 有技能库 |
| 07 | 记忆系统(三层) | 有脑子(跨会话记得) |
| 08 | 任务规划 TodoList | 有条理 |
| 09 | 子代理 Subagent | 有分身 |
| 10 | Agent Team 团队 | 有团队 |
| 11 | MCP 协议 | 有插座(外部工具即插即用) |
| 12 | Hooks 生命周期 | 有安检(拦截 + 改写 + 审计) |
| 13 | 多模型路由 | 有调度台(选模型 + 记成本) |
| 14 | 目标驱动 Agent | 有目标感(订契约 + 自主推进 + 验收) |
| 15 | 部署上线 + 课程总结 | 能上路(Web UI + 流式 + Docker) |
贯穿始终不变的是三条纪律:事件溯源 (每轮消息产生即落盘,记忆完整可回放)、统一入口 (工具走 execute_tool,LLM 走 llm_call/llm_call_stream,能力走 build_system_prompt)、审计可观测(审计日志管合规,usage_log 管成本,goal_log 管进度)。
适用场景
- 想把自己写的 Agent 分享给别人用:Web UI 是门槛最低的"产品外衣",打开即用
- 要"边生成边展示"的对话产品:长回答、写代码、写方案,SSE 打字机体验近乎刚需
- 团队协作 / 内部工具:Docker 一次构建处处可跑,新同事不用手搓环境
- 接下来往哪长 :把进程内
_SESSIONS/GOAL_STATE换进 Redis,给/chat加鉴权,把内嵌 HTML 换成正式前端,接入真实多供应商模型 ------ 骨架已搭好,取决于你
结语(不是下期预告,是收官谢幕)
第 15 期讲到这就结束了。这不是"下期预告",而是"收官复盘":15 期,把 Alex 从一个只会调 API 的婴儿,养成了一台能自主完成目标、还能对外服务的 Agent ------ 而最后一期做的事,只是给它装上最后一块外壳:Web UI 把它请进浏览器,SSE 流式给了它打字机的产品体验,Docker 让它从"我机器上能跑"变成"一次构建、处处可跑"。
从零搭建 AI 助手,至此闭环。你可以把 code/step15_deploy/ 当起点继续演进 ------ 发动机和车都有了,接下来往哪开,方向盘在你手里。
感谢各位看官的一路陪伴,大家都再接再厉!