Python 智能体开发实战 10:本地跑得好好的,一并发就崩?把它做成一个能上线的服务

0 阅读11分钟

从本地脚本到上线服务

给「启明英语」做的小 E,前九篇攒下的代码,全都在 demo.py 里跑得漂漂亮亮。直到同事来找我,说想把批改接到小程序里给学员用。

我第一反应是"很简单,把 demo 改改就行"。结果一动手才发现,本地能跑和能用,中间隔着一整座桥:小程序怎么调我的 Python?两个学生同时提交,会话历史会不会串?模型返回慢了,用户盯着白屏吗?

这一篇是这个系列的收官,我们把前九篇的能力装进一个 FastAPI 服务——结构化输出、上下文管理、稳定性兜底,全部收拢成三个 HTTP 接口,真正推到线上。

一、先扫盲:脚本和服务差在哪

1. 脚本是"你跑一下",服务是"谁都能调"

demo.py 是你双击运行,输出打到终端。产品需要的是:任何客户端(小程序、网页、群机器人)通过 HTTP 请求,拿到结构化响应。 这一步的载体就是 Web 服务框架——这里用 FastAPI,轻、类型友好、自带交互式接口文档。

2. 并发和隔离,是脚本里不存在、服务里必须处理的问题

脚本一次只服务你一个人。服务要同时面对成百上千个学生:

  • 会话隔离:A 的问题不能出现在 B 的上下文里。
  • 无状态:每次请求是独立的,记忆要由服务端自己管。
  • 兜底:任何异常都不能返回一堆看不懂的栈给前端。

这三件事,脚本里一样都没有,所以"改改 demo 就能上线"是个幻觉。

3. 依赖注入:同一份业务代码,测试和生产的区别只在"喂哪个模型"

这是服务化最值钱的一个决定。把模型当参数传进来:

def create_app(llm) -> FastAPI:
    ...

测试传假模型,生产传真模型,业务逻辑一份代码。没有它,你每次想跑测试都得连真 API、花真钱。

二、工程设计:把九篇的能力收拢

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 把它写成代码:

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 篇的结构化输出收拢成一个函数。保留两个通路:

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)

上线后请求来自谁、内容多长,都不归你控制。所以服务端必须自己设限:

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 篇讲过会话隔离,这里补上"服务端常驻进程"带来的两个新问题——并发安全和内存无限涨:

@dataclass
class Conversation:
    session_id: str
    history: deque = field(default_factory=lambda: deque(maxlen=MAX_TURNS * 2))

deque(maxlen=...) 会在超长时自动丢掉最老的,天然实现滑窗。仓库本身带锁和 LRU 淘汰:

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>,再空一行,循环往复:

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。能省则省,这是工程里最划算的克制。

SSE 流式时序

八、组装:三个接口(app.py)

create_app(llm) 工厂函数把上面所有模块组装成三个接口:

方法路径作用
GET/health探活,给监控/负载均衡用
POST/correct同步批改,返回结构化结果
POST/chat/stream流式对话,SSE 逐字返回

批改要稳定、要结构,对话要快、要流式——所以拆成两个接口,而不是硬塞进一个。看流式接口的组装:

@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)

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 直接打接口):

一、健康检查 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 个用例全绿,全程离线:

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 最阴险,因为它不报错,只是回答悄悄变蠢。

十一、上线清单:从"能跑"到"能用"还差这些

代码跑通了,不代表能上线。这张清单是我踩坑踩出来的,每一条都对应一次真实事故:

  1. .env 配好 Key,别提交 Git:Key 进仓库,等于把钱包密码发群里。.gitignore 里已经排除了 .env,养成习惯。
  2. /health 接进监控:否则服务挂了没人知道,等家长投诉才发现——那时已经晚了。
  3. 加限流:limits.py 只限长度不限频率,恶意请求能刷爆额度,要再接一层频率限制(比如按 IP 或 token 限次)。
  4. 会话仓库换 Redis:sessions.py 是内存版,多进程部署会各存各的,用户换个请求就"失忆",必须换外部存储。
  5. 接日志和告警:别用 print。出错时你需要的是带时间戳、能检索的日志,以及一个能半夜叫醒人的告警。
  6. 结构化输出加缓存:同一句"批改"结果可以缓存,重复请求不重复花钱——这和第 3 篇的上下文缓存是同一个省钱思路。

这些看起来是"运维的活",但上线从来不只是写代码。产品跑在线上,你就要对它的每一次调用负责。

十二、踩坑清单

  1. "改改 demo 就能上线":脚本没有并发、隔离、兜底,直接上线必翻车。
  2. 响应体不做 ok/error 分层:前端被迫 try 解析,失败和成功混在一起。
  3. 流式不发 done:前端永远不知道这轮结束,把超时当结束。
  4. 服务端信了前端校验:不设输入上限,恶意请求刷爆额度。
  5. 会话放内存就跑多进程:各进程各存各的,用户换个请求就"失忆"。
  6. Key 提交进 Git:泄露一次,账单惊魂一次。

十三、系列收尾:十篇,一条从零到上线的路

回头把十篇串起来,其实是一条完整的路:

  • 第 1-2 篇:环境配好,提示词写对——先把话说清楚。
  • 第 3 篇:管住上下文,省下真金白银——先别烧冤枉钱。
  • 第 4 篇:输出结构化——让程序能"读懂"模型的话。
  • 第 5 篇:RAG 检索——让它开卷考,别瞎编。
  • 第 6 篇:LangGraph 状态机——把复杂流程拆成可回退的步骤。
  • 第 7 篇:工具调用——给它接上真正能动手的"手"。
  • 第 8 篇:稳定性——重试、超时、降级三条保险丝。
  • 第 9 篇:评测——让每一次改动都能被打分。
  • 第 10 篇:服务化——把这一切装进一个能上线的服务。

这就是用 Python 做一个智能体产品全过程的骨架。你不需要一次掌握全部——从第 1 篇开始,写一个真正能跑的 demo,比读完十篇更有用。 需要哪块能力,就回来翻对应的那一篇。

最后说几句这十篇反复出现、也最想留给你的三句话:先把话说清楚(提示词),再别烧冤枉钱(上下文),然后让程序读懂它(结构化输出)。 这三件事做扎实了,后面的检索、编排、工具、稳定性、评测、上线,都是顺水推舟。

系列写到这里就收官了。但"启明英语的小 E"这个故事还没结束——它只是你手上下一个项目的一个影子。


最后一句:能跑的代码是你的本事,能上线的服务才是别人的依赖——差的那段路,叫工程。