
给「启明英语」做的小 E,前九篇攒下的代码,全都在 demo.py 里跑得漂漂亮亮。直到同事来找我,说想把批改接到小程序里给学员用。
我第一反应是"很简单,把 demo 改改就行"。结果一动手才发现,本地能跑和能用,中间隔着一整座桥:小程序怎么调我的 Python?两个学生同时提交,会话历史会不会串?模型返回慢了,用户盯着白屏吗?
这一篇是这个系列的收官,我们把前九篇的能力装进一个 FastAPI 服务------结构化输出、上下文管理、稳定性兜底,全部收拢成三个 HTTP 接口,真正推到线上。
一、先扫盲:脚本和服务差在哪
1. 脚本是"你跑一下",服务是"谁都能调"
demo.py 是你双击运行,输出打到终端。产品需要的是:任何客户端(小程序、网页、群机器人)通过 HTTP 请求,拿到结构化响应。 这一步的载体就是 Web 服务框架------这里用 FastAPI,轻、类型友好、自带交互式接口文档。
2. 并发和隔离,是脚本里不存在、服务里必须处理的问题
脚本一次只服务你一个人。服务要同时面对成百上千个学生:
- 会话隔离:A 的问题不能出现在 B 的上下文里。
- 无状态:每次请求是独立的,记忆要由服务端自己管。
- 兜底:任何异常都不能返回一堆看不懂的栈给前端。
这三件事,脚本里一样都没有,所以"改改 demo 就能上线"是个幻觉。
3. 依赖注入:同一份业务代码,测试和生产的区别只在"喂哪个模型"
这是服务化最值钱的一个决定。把模型当参数传进来:
python
def create_app(llm) -> FastAPI:
...
测试传假模型,生产传真模型,业务逻辑一份代码。没有它,你每次想跑测试都得连真 API、花真钱。
二、工程设计:把九篇的能力收拢
bash
10-服务化上线/
├── src/
│ ├── settings.py # 配置(复用第 1 篇)
│ ├── llm.py # 模型工厂(复用第 1 篇)
│ ├── schema.py # 请求/响应的数据结构
│ ├── corrector.py # 批改核心逻辑(结构化输出)
│ ├── limits.py # 输入校验与上限
│ ├── sessions.py # 会话仓库(隔离 + LRU 淘汰)
│ ├── streaming.py # SSE 流式输出
│ ├── app.py # FastAPI 应用
│ └── server.py # uvicorn 启动入口
├── tests/ # 27 个用例,离线全绿
└── demo.py
一张表看清"前几篇的能力都去哪了":
| 前篇能力 | 落点 |
|---|---|
| 结构化输出(第 4 篇) | correct() 返回 Correction,不返回散文 |
| 上下文管理(第 3 篇) | sessions.py 会话隔离 + 滑窗历史 |
| 稳定性兜底(第 8 篇) | 失败永远返回可解析的 ok:false |
三、先定合同:数据结构(schema.py)
服务上线后,请求体和响应体就是前后端之间的合同。用 pydantic 把它写成代码:
python
class Correction(BaseModel):
error_types: list[ErrorType] = Field(default_factory=list)
needs_rewrite: bool = False
corrected_sentence: str = ""
comment: str = ""
class CorrectResponse(BaseModel):
ok: bool
correction: Correction | None = None
error: str | None = None
注意 CorrectResponse 外面包了一层 ok/error,而不是直接把 Correction 当响应体。这么设计有一个很实际的理由:前端永远先看 ok 再看 correction,永远不用 try 解析。 失败和成功走同一个结构,只是 ok 翻一下------这是服务端"失败兜底"的第一课。
四、批改逻辑:两个通路(corrector.py)
把第 4 篇的结构化输出收拢成一个函数。保留两个通路:
python
SYSTEM = """你是「启明英语」的 AI 助教小 E,负责批改学生写的英语句子。
要求:
1. 逐句找出错误,一个错误一条,不要漏
2. 句子本身没错误时,明确说"这句没问题"
3. 只输出 JSON,不要输出其它任何文字
"""
PROMPT = ChatPromptTemplate.from_messages(
[
("system", SYSTEM),
("human", "{format_instructions}\n\n学生写的句子:\n{essay}"),
]
)
def correct_structured(llm, essay) -> Correction:
"""通路一:with_structured_output,走 function calling,最稳。"""
structured = llm.with_structured_output(Correction)
return structured.invoke(PROMPT.format(essay=essay))
def correct_parse(llm, essay) -> Correction:
"""通路二:PydanticOutputParser 自己 parse,适合模型不支持工具调用的场合。"""
parser = PydanticOutputParser(pydantic_object=Correction)
prompt = PROMPT.partial(format_instructions=parser.get_format_instructions())
response = llm.invoke(prompt.format(essay=essay))
return parser.parse(getattr(response, "content", ""))
服务里默认用 correct_parse------它不依赖模型的工具调用能力,而且 parse 过程可控可测:模型"没按格式来"时,我们能拿到原始文本,而不是一个莫名奇妙的异常。format_instructions 会把 schema 的字段说明、类型、示例自动塞进提示词,这是第 2 篇"把提示词做成对象"的又一次兑现。
五、输入防线:服务端不信任前端(limits.py)
上线后请求来自谁、内容多长,都不归你控制。所以服务端必须自己设限:
python
MAX_ESSAY_CHARS = 2000
def validate_essay(essay: str) -> str:
text = (essay or "").strip()
if not text:
raise InputError("essay 不能为空")
if len(text) > MAX_ESSAY_CHARS:
raise InputError(f"essay 过长,最多 {MAX_ESSAY_CHARS} 字符")
return text
批改接口本来吃的是"一句话",超长的一般是误传或恶意请求。设 2000 字符,超过直接 422。别信任前端"已经校验过了"这句话。
六、会话隔离:并发安全 + 会清理(sessions.py)
第 3 篇讲过会话隔离,这里补上"服务端常驻进程"带来的两个新问题------并发安全和内存无限涨:
python
@dataclass
class Conversation:
session_id: str
history: deque = field(default_factory=lambda: deque(maxlen=MAX_TURNS * 2))
deque(maxlen=...) 会在超长时自动丢掉最老的,天然实现滑窗。仓库本身带锁和 LRU 淘汰:
python
class SessionStore:
def get(self, session_id: str) -> Conversation:
with self._lock:
if session_id not in self._sessions:
self._sessions[session_id] = Conversation(session_id=session_id)
if len(self._sessions) > self._max: # 超上限淘汰最久的
self._sessions.popitem(last=False)
else:
self._sessions.move_to_end(session_id)
return self._sessions[session_id]
内存版会话仓库够单进程小规模用,多进程部署必须换成 Redis------这是上线清单里的一条硬项,后面会讲。
七、流式输出:字是一个个蹦出来的(streaming.py)
学生提交后,看着空白页等 3 秒,和看着字一个个蹦出来,体感天差地别。流式不是炫技,它直接决定"这个产品快不快"的主观感受。
SSE(Server-Sent Events)的格式就两条:一行 data: <json>,再空一行,循环往复:
python
def sse_event(payload: dict) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
def sse_events(llm, messages):
for token in stream_tokens(llm, messages):
yield sse_event({"delta": token})
yield sse_event({"done": True}) # 结束标志,绝不能少
最后那条 {"done": true} 是整段代码的命门:不发它,前端就不知道这轮说完了,会一直等,或把超时误判成结束。
这里有个常见疑问:为什么不直接用 WebSocket? 因为对话是"一问一答"的单向流,SSE 更简单------它走普通 HTTP,不需要专门的连接管理、不需要处理心跳,还天然能被负载均衡和日志系统看懂。只有当你需要"双向实时、客户端也能主动发"(比如多人协作白板)时,才上 WebSocket。能省则省,这是工程里最划算的克制。

八、组装:三个接口(app.py)
create_app(llm) 工厂函数把上面所有模块组装成三个接口:
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /health |
探活,给监控/负载均衡用 |
| POST | /correct |
同步批改,返回结构化结果 |
| POST | /chat/stream |
流式对话,SSE 逐字返回 |
批改要稳定、要结构,对话要快、要流式------所以拆成两个接口,而不是硬塞进一个。看流式接口的组装:
python
@app.post("/chat/stream")
def chat_stream(req: ChatRequest) -> StreamingResponse:
message = validate_message(req.message)
session_id = req.session_id or "default"
conversation = store.get(session_id)
conversation.append_user(message)
messages = conversation.to_messages(store.system)
def generate():
parts = []
for token in stream_tokens(llm, messages):
parts.append(token)
yield sse_event({"delta": token})
conversation.append_ai("".join(parts)) # 回答也记进历史
yield sse_event({"done": True})
return StreamingResponse(generate(), media_type="text/event-stream")
两处细节:流式过程边吐边把 token 累积起来,结束后写回历史------这样下一轮才"记得"这次聊了什么;失败时抛 HTTPException(422),前端拿到的是一个明确的错误,而不是 500 加一坨栈。

九、启动:uvicorn 跑起来(server.py)
python
settings = load_settings()
llm = build_llm(settings, temperature=0.3) # 批改要稳定,温度压到 0.3
app = create_app(llm)
import uvicorn
uvicorn.run(app, host=args.host, port=args.port)
python src/server.py 起服务,浏览器打开 http://127.0.0.1:8000/docs 就是 FastAPI 自带的交互式接口文档------不用装 Postman,直接在网页上试接口。
十、跑起来:三个接口走一遍
python demo.py 全程离线(不起真实服务器,用 TestClient 直接打接口):
css
一、健康检查 GET /health
200 {"status": "ok", "model": "FakeListChatModel"}
二、同步批改 POST /correct
状态码:200
ok :True
错误类型:['时态']
需重写 :True
改写 :I went to the park yesterday.
解释 :yesterday 是过去时间,go 要改成 went。
边界:空 essay 会被拦下(返回 422 而不是 500)
状态码:422
三、流式对话 POST /chat/stream(SSE)
content-type: text/event-stream; charset=utf-8
data: {"delta": "这"}
data: {"delta": "句"}
data: {"delta": "时"}
data: {"delta": "态"}
...
data: {"done": true}
看第三段:流式接口吐出来的是一串 delta,最后以 done 收尾------这就是前端逐字渲染的原料。
测试结果,27 个用例全绿,全程离线:
bash
tests/test_corrector.py 4 个 结构化批改、两个通路、无错句
tests/test_limits.py 6 个 空/超长输入被拒、边界长度
tests/test_streaming.py 4 个 SSE 格式、逐字流、done 收尾
tests/test_sessions.py 6 个 隔离、滑窗、LRU 淘汰
tests/test_app.py 7 个 健康检查、批改、流式、历史记忆
27 passed in 1.40s
这组测试里我最在意 test_stream_keeps_history_across_turns:它断言"流式完成后,回答真的进了下一轮请求"------上下文记忆这种 bug 最阴险,因为它不报错,只是回答悄悄变蠢。
十一、上线清单:从"能跑"到"能用"还差这些
代码跑通了,不代表能上线。这张清单是我踩坑踩出来的,每一条都对应一次真实事故:
.env配好 Key,别提交 Git :Key 进仓库,等于把钱包密码发群里。.gitignore里已经排除了.env,养成习惯。/health接进监控:否则服务挂了没人知道,等家长投诉才发现------那时已经晚了。- 加限流 :
limits.py只限长度不限频率,恶意请求能刷爆额度,要再接一层频率限制(比如按 IP 或 token 限次)。 - 会话仓库换 Redis :
sessions.py是内存版,多进程部署会各存各的,用户换个请求就"失忆",必须换外部存储。 - 接日志和告警 :别用
print。出错时你需要的是带时间戳、能检索的日志,以及一个能半夜叫醒人的告警。 - 结构化输出加缓存:同一句"批改"结果可以缓存,重复请求不重复花钱------这和第 3 篇的上下文缓存是同一个省钱思路。
这些看起来是"运维的活",但上线从来不只是写代码。产品跑在线上,你就要对它的每一次调用负责。
十二、踩坑清单
- "改改 demo 就能上线":脚本没有并发、隔离、兜底,直接上线必翻车。
- 响应体不做
ok/error分层:前端被迫 try 解析,失败和成功混在一起。 - 流式不发
done:前端永远不知道这轮结束,把超时当结束。 - 服务端信了前端校验:不设输入上限,恶意请求刷爆额度。
- 会话放内存就跑多进程:各进程各存各的,用户换个请求就"失忆"。
- Key 提交进 Git:泄露一次,账单惊魂一次。
十三、系列收尾:十篇,一条从零到上线的路
回头把十篇串起来,其实是一条完整的路:
- 第 1-2 篇:环境配好,提示词写对------先把话说清楚。
- 第 3 篇:管住上下文,省下真金白银------先别烧冤枉钱。
- 第 4 篇:输出结构化------让程序能"读懂"模型的话。
- 第 5 篇:RAG 检索------让它开卷考,别瞎编。
- 第 6 篇:LangGraph 状态机------把复杂流程拆成可回退的步骤。
- 第 7 篇:工具调用------给它接上真正能动手的"手"。
- 第 8 篇:稳定性------重试、超时、降级三条保险丝。
- 第 9 篇:评测------让每一次改动都能被打分。
- 第 10 篇:服务化------把这一切装进一个能上线的服务。
这就是用 Python 做一个智能体产品全过程的骨架。你不需要一次掌握全部------从第 1 篇开始,写一个真正能跑的 demo,比读完十篇更有用。 需要哪块能力,就回来翻对应的那一篇。
最后说几句这十篇反复出现、也最想留给你的三句话:先把话说清楚(提示词),再别烧冤枉钱(上下文),然后让程序读懂它(结构化输出)。 这三件事做扎实了,后面的检索、编排、工具、稳定性、评测、上线,都是顺水推舟。
系列写到这里就收官了。但"启明英语的小 E"这个故事还没结束------它只是你手上下一个项目的一个影子。
最后一句:能跑的代码是你的本事,能上线的服务才是别人的依赖------差的那段路,叫工程。