LLM 接口返回了 200,但结果能直接给用户吗?

128 阅读7分钟

LLM 接口返回了 200,但结果能直接给用户吗?

你调了一个大语言模型的 API,拿到了 HTTP 200,返回体里也确实有一段中文文本。看起来一切正常。你把它塞进响应返回给前端——然后用户看到了这么个东西:

这是一段正常的摘要文本。<think>我需要先分析一下这段文本的核心要点...</think>

一段模型的内心独白,就这么堂而皇之地出现在了用户面前。

这不是假设场景。我在做一个文本处理 API 的时候,第一周就连着踩了三个类似的坑:接口都能正常返回,状态码都是 200,但交给用户的结果要么夹带了不该有的东西,要么跟预期差了十万八千里。事后想想,"接口能跑"和"结果能用"之间,隔着好几道你没写的防线。 这篇文章讲的就是这几道防线。

先说一下这个项目

我在做一个开源的 AI 开发训练项目,第一个动手做的是一个文本处理 API。技术栈很简单:FastAPI 做框架,对接 OpenAI 兼容的上游模型,对外暴露三个端点——总结(/summarize)、要点提取(/key-points)和改写(/rewrite)。架构就三层:路由层管 HTTP,任务层构造 Prompt 并清洗结果,客户端层负责和上游通信。

到第一周末的时候,三个接口都跑通了,本地测试也全过了。按正常思路,下一步应该加功能了对吧?

不是。真实样例一跑,问题就来了。

三个让我意识到"能跑≠能用"的问题

第一个坑是模型返回里夹带了推理过程。

部分模型——特别是带推理能力的版本——会把自己的思考过程用 <think>...</think> 标签包起来,混在 message.content 里一起返回。如果你的代码跟我一开始一样直接信任了 content 字段,用户看到的就是正文夹杂着一堆他完全看不懂的模型内心戏。

最要命的是,只靠看 HTTP 状态码根本发现不了这个问题。200,有内容,看着挺好——但内容本身是脏的。

第二个坑更隐蔽:改写接口的输出风格飘了。

/rewrite 的设计意图是轻度改写。但真实样例一跑我发现,同样一段短句,模型有时会改成正儿八经的书面语,有时扩写 30%,偶尔甚至自作主张加一个标题。这种问题不报错,日志里也看不出异常,但调用方拿到的结果跟预期完全不是一回事。

为什么?因为我初版 Prompt 只写了"用更清晰自然的中文改写",没告诉模型什么不行。LLM 就是这样——你不明确说不行的,它默认都行。

第三个坑是错误响应一团乱。

接口报 500 或 502 的时候,返回体有时是 FastAPI 默认的 HTML 错误页,有时是一段不带上下文的纯文本。调用方没法按固定字段做判断,测试写不出稳定的断言,出了问题也追不到是哪个请求挂的。

三个坑根因各不相同,但共同指向一个教训:不要因为接口"能跑"就觉得它"没问题"。

我的做法:先加固,再加功能

发现这些问题之后,我做了一个当时看起来有点反直觉的决定:停下来,不加新接口,先把现有的三个接口加固到"敢交付"的程度。

同时给自己画了明确的边界——这一轮只统一 500 和 502 两类错误,不动 FastAPI 默认的 422 校验格式;只补最小的请求追踪链路,不搞完整的日志平台。项目体量还很小,先把接口契约稳住,收益远大于继续堆功能。

输出清洗:模型返回的不一定是最终结果

解决 <think> 泄漏的办法本身不复杂,用正则把推理标签清掉就行。但关键是把它做成代码里的固定规则,而不是出一次问题手工修一次:

THINK_BLOCK_RE = re.compile(r"<think>.*?</think>\s*", re.DOTALL)

def _extract_result(response_data: dict) -> str:
    content = response_data["choices"][0]["message"]["content"]
    cleaned = THINK_BLOCK_RE.sub("", content).strip()
    if not cleaned:
        raise ApiError(
            status_code=502,
            code="UPSTREAM_EMPTY_RESPONSE",
            message="Upstream returned an empty response",
            detail="response content became empty after cleanup",
        )
    return cleaned

这里有个细节值得说一下:清洗完之后如果内容变空了,不能默默返回一个空字符串,得显式报 502。不然调用方拿到的是一个 200 + 空内容的响应——比直接报错还难排查。

改写接口的风格飘移我用了两头夹击的办法:Prompt 端加了明确的限制(不扩写、不加标题、短文只润色),代码端加了一层结果归一化,自动去掉模型擅自添加的"改写后:"前缀和首行标题。不把赌注全压在 Prompt 上,因为 Prompt 约束本质上是"建议",不是"保证"。

输入边界:在门口就把脏数据拦住

三个文本接口共用一个 Pydantic 请求模型,做的事情很基础:先 strip() 再校验,拦住纯空格;最大长度限到 4000 字符;三个接口的输入形态统一。

class TextTaskRequest(BaseModel):
    text: str = Field(..., min_length=1)

    @field_validator("text")
    @classmethod
    def validate_text(cls, value: str) -> str:
        normalized = value.strip()
        if not normalized:
            raise ValueError("text must not be empty")
        if len(normalized) > MAX_TEXT_LENGTH:
            raise ValueError(f"text must be at most {MAX_TEXT_LENGTH} characters")
        return normalized

为什么要在模型层统一做,而不是在每个路由里各写一套?因为校验逻辑一旦分散,不一致只是时间问题。这个接口拦了空格那个没拦,这个限了 4000 字那个忘了——到后面出了问题你都不确定该查哪一层。

错误契约:让报错变成一件可靠的事

统一后的错误响应长这样:

{
  "code": "UPSTREAM_REQUEST_FAILED",
  "message": "Upstream request failed",
  "detail": "request timed out",
  "request_id": "01a1200e-5c70-4678-8d47-c951a9cd54f6"
}

四个字段各管各的:code 给调用方做程序化分支,message 给人看,detail 给排查用,request_id 把请求和响应串起来。调用方终于可以按固定结构写错误处理了,出了问题也能追到是哪个请求出的。

验证固化:确保下周回来还能跑

光改代码不够,得让改完的东西能被持续验证。比如下面这段测试,验的不是"错误接口有没有返回",而是"返回的 request_id 是不是一个合法的 UUID"——这个区别很重要,前者只是检查"有东西回来了",后者是在检查"回来的东西结构对不对":

def assert_has_request_id(payload: dict) -> None:
    request_id = payload.get("request_id")
    assert isinstance(request_id, str)
    assert request_id
    assert str(UUID(request_id)) == request_id

第一周结束时,本地 pytest 跑通了 12 条测试,覆盖健康检查、正常路径、错误映射、输入边界和 request_id 结构。数量不多,但够用——至少下周回来跑一遍,就知道之前做的东西有没有被破坏。

image.png

读者带走清单

如果你也在用 FastAPI 或者别的什么框架封装 LLM 能力,这几件事值得对照看看:

  1. 模型输出你清洗了吗? 别默认信任 content 字段。推理标签、格式前缀、Markdown 标记都可能混在里面,原样返回迟早出事。
  2. 输入边界在哪一层拦的? 空字符串、纯空格、超长文本——每个接口各自处理的话,不一致只是时间问题。在请求模型层统一做最省心。
  3. 错误响应结构稳定吗? 调用方拿到的错误如果有时是 HTML 有时是纯文本,它没法写出靠谱的错误处理。至少给一个 code + message + detail 的固定结构。
  4. 有没有最小的请求追踪? 哪怕只是一个 request_id,能把请求和响应串起来,排查效率就完全不一样。
  5. 你的验证下周还能跑吗? 测试结果只存在终端历史里的话,保质期大概就一天。