【Bug已解决】[serge] integration failure triage - 2026-06-30 解决方案
一、现象长什么样
serje 为了省去每次请求都重新加载模型,把模型+tokenizer 做成模块级单例,所有聊天请求共用同一个model实例。升级 transformers 后,出现一类「时好时坏」的怪现象:
- 第一个用户请求正常,第二个用户请求开始重复、截断、或停不下来;
- 你改了某个生成参数(比如把
repetition_penalty设成 1.2)调试,结果所有后续请求都带上了 1.2,即使你代码里没再传; - 重启服务后第一个请求又正常,过几个请求又乱;
- 偶尔不同会话之间「串味」——A 用户设置的
temperature影响到了 B 用户。
最迷惑的是:单看每一行代码都没问题,但多请求并发/连续后就错。这是典型的「单例被请求间共享状态污染」。
二、背景
很多集成为了性能会这么写:
# module-level 单例 _MODEL = None _TOKENIZER = None def get_model(): global _MODEL, _TOKENIZER if _MODEL is None: _MODEL = AutoModelForCausalLM.from_pretrained("your-model") _TOKENIZER = AutoTokenizer.from_pretrained("your-model") return _MODEL, _TOKENIZER def chat(text, repetition_penalty=1.2): model, tok = get_model() # 错误:直接改了单例的 generation_config,会持久化 model.generation_config.repetition_penalty = repetition_penalty ids = tok(text, return_tensors="pt").input_ids.to(model.device) out = model.generate(ids) return tok.decode(out[0][ids.shape[1]:])问题核心:model.generation_config是挂在单例上的可变对象。你在一个请求里改了它,这个改动会一直留在单例上,污染后续所有请求。升级 transformers 后,generation_config的默认值/字段有调整,这种「改了不还原」的写法更容易暴露——比如新版默认eos_token_id变了,你某次调试又改了它,后续请求全受影响。
另一个污染源:把inputs/attention_mask/past_key_values缓存在单例属性上,跨请求没清,导致 A 的上下文泄漏到 B。
三、根因
根因一句话:serje 用模块级单例复用模型,却在请求处理里直接修改了单例上可变、跨请求共享的状态(如generation_config、缓存的输入/历史),导致一次请求的改动污染后续所有请求。
三点展开:
- 共享可变状态:
model.generation_config是单例上的可变对象,被请求直接改,改动持久化。 - 改动不还原:改了
repetition_penalty/temperature/max_new_tokens后没还原,后续请求继承脏值。 - 跨请求串味:A 用户的参数/历史缓存在单例属性,B 用户请求读到,造成串味。
不是模型问题,是「单例 + 可变共享状态 + 请求间不隔离」的经典陷阱。
四、最小可运行复现
不依赖真实模型,模拟「单例 generation_config 被请求污染」:
class FakeGenerationConfig: def __init__(self): self.repetition_penalty = 1.0 self.temperature = 1.0 class FakeModel: def __init__(self): self.generation_config = FakeGenerationConfig() _SINGLETON = FakeModel() def chat_buggy(text, repetition_penalty=1.2): # 直接改单例的 generation_config(错误) _SINGLETON.generation_config.repetition_penalty = repetition_penalty return _SINGLETON.generation_config.repetition_penalty print("请求1 设 1.2 ->", chat_buggy("hi", 1.2)) # 1.2 符合预期 print("请求2 不设 ->", chat_buggy("hi")) # 期望 1.0,实际 1.2(被污染!) print("请求3 不设 ->", chat_buggy("hi")) # 还是 1.2跑出来:请求1 设 1.2,请求2/3 即使没传也变成 1.2——单例状态被污染,后续请求全受影响。这就是「时好时坏 / 串味」的精确复现。
五、解决方案(第一层:最小直接修复)
最小修复:绝不修改单例上的generation_config等共享可变对象;每次请求把生成参数作为kwargs传给generate,让 transformers 在调用内合并,不改原式。
_MODEL = None _TOKENIZER = None # 注意:把「每请求默认参数」放在单例之外,作为不可变模板 DEFAULT_GEN = {"max_new_tokens": 256, "do_sample": True, "temperature": 0.7} def get_model(): global _MODEL, _TOKENIZER if _MODEL is None: _MODEL = AutoModelForCausalLM.from_pretrained("your-model") _TOKENIZER = AutoTokenizer.from_pretrained("your-model") return _MODEL, _TOKENIZER def chat(text, repetition_penalty=1.2): model, tok = get_model() ids = tok(text, return_tensors="pt").input_ids.to(model.device) # 关键:参数只作为本次 generate 的 kwargs,不改 model.generation_config out = model.generate( ids, repetition_penalty=repetition_penalty, **DEFAULT_GEN, ) return tok.decode(out[0][ids.shape[1]:], skip_special_tokens=True)要点:
- 生成参数一律通过
generate(**kwargs)传入,transformers 会在调用内部临时合并,不改model.generation_config原对象。 - 每请求参数隔离,互不污染;
DEFAULT_GEN作为不可变模板,不复用可变对象。 - 绝不写
model.generation_config.xxx = ...(除非你明确要在进程生命周期内固定它,且清楚后果)。
这一步单独就让「串味 / 时好时坏」消失。
六、解决方案(第二层:结构性改进)
第一层是「不修改单例状态」。但多个入口都可能手滑改generation_config或缓存输入。更稳的做法把「单例如何安全复用、请求参数如何隔离」收敛成单一加载器。
from dataclasses import dataclass, field from typing import Dict, Any, Optional import copy @dataclass class SergeModelSingleton: """serje 模型单例 + 请求参数隔离的单一管理。""" model_name: str # 不可变的默认生成模板(每次请求基于它做副本) default_gen: Dict[str, Any] = field(default_factory=dict) _model = None _tokenizer = None def get(self): if self._model is None: from transformers import AutoModelForCausalLM, AutoTokenizer self._model = AutoModelForCausalLM.from_pretrained(self.model_name) self._tokenizer = AutoTokenizer.from_pretrained(self.model_name) return self._model, self._tokenizer def generate(self, text: str, **per_request) -> str: model, tok = self.get() # 基于不可变模板做浅拷贝,绝不改原对象 kwargs = copy.copy(self.default_gen) kwargs.update(per_request) # 仅本次请求生效 ids = tok(text, return_tensors="pt").input_ids.to(model.device) out = model.generate(ids, **kwargs) # kwargs 传参,不改 generation_config return tok.decode(out[0][ids.shape[1]:], skip_special_tokens=True) def reset_defaults(self, **kw): # 若真要改默认,只改模板副本,不动单例可变状态 self.default_gen.update(kw) # 用法 sg = SergeModelSingleton("your-model", default_gen={"max_new_tokens": 256, "temperature": 0.7}) r1 = sg.generate("你好", repetition_penalty=1.2) # 仅本次带 1.2 r2 = sg.generate("继续") # 不带 1.2,用默认结构收益:
- 单例安全复用:模型只加载一次(性能不变),但请求参数通过
copy + kwargs隔离。 - 不可变模板:
default_gen是模板,改默认用reset_defaults,不碰model.generation_config。 - 可审计:所有生成走
generate(),不会再有人手滑改单例状态。
七、解决方案(第三层:断言 / CI 守护)
写 pytest 守三条:(1) 单例只加载一次;(2) 请求参数不污染默认模板;(3) 多次请求互不串味。
import pytest from your_lib import SergeModelSingleton def test_singleton_loads_once(): loads = {"n": 0} class FakeModel: pass class FakeTok: pass sg = SergeModelSingleton.__new__(SergeModelSingleton) sg.model_name = "m" sg.default_gen = {} sg._model = None sg._tokenizer = None def fake_get(): loads["n"] += 1 if sg._model is None: sg._model, sg._tokenizer = FakeModel(), FakeTok() return sg._model, sg._tokenizer sg.get = fake_get sg.get(); sg.get(); sg.get() assert loads["n"] == 1, "单例应只加载一次" def test_per_request_does_not_pollute_default(): sg = SergeModelSingleton.__new__(SergeModelSingleton) sg.model_name = "m" sg.default_gen = {"temperature": 0.7} sg._model = None sg._tokenizer = None # 模拟 generate 只基于副本 base = dict(sg.default_gen) kwargs = dict(sg.default_gen); kwargs.update({"repetition_penalty": 1.2}) assert sg.default_gen == base, "默认模板不应被改动" assert kwargs["repetition_penalty"] == 1.2 def test_no_cross_request_leak(): # 连续请求,参数互不影响 captured = [] def fake_generate(text, **kw): captured.append(kw) sg = SergeModelSingleton.__new__(SergeModelSingleton) sg.model_name = "m" sg.default_gen = {"temperature": 0.7} sg._model = None sg._tokenizer = None sg.generate = lambda text, **pr: fake_generate(text, **{**sg.default_gen, **pr}) sg.generate("a", repetition_penalty=1.2) sg.generate("b") # 不带 repetition_penalty assert "repetition_penalty" not in captured[1] assert captured[0]["repetition_penalty"] == 1.2CI 常驻跑这三条后,任何「又改单例 generation_config」「请求间串味」的回归都会立刻爆红。
八、排查清单
serje「单例 + 时好时坏 / 串味」时按顺序查:
- 先确认是不是「第一个请求正常、后续乱」——是的话高度怀疑单例状态污染。
- 全局搜
model.generation_config./tokenizer.xxx =,看是否在某请求里修改了单例可变状态。 - 把所有生成参数改为
generate(**kwargs)传入,绝不写model.generation_config.xxx =。 - 确认没有把
inputs/past_key_values/ 对话历史缓存在单例属性上跨请求复用。 - 每请求基于「不可变默认模板的副本」合并参数,改默认用专门的
reset_defaults,不动单例。 - 多线程/多请求时,确认单例加载有锁(
if None竞态也会偶尔炸)。 - 升级 transformers 后,跑「连续 5 个不同参数请求」冒烟,断言输出互不影响。
九、小结
serje 升级后的「时好时坏 / 请求串味」,根子是模型做成了模块级单例复用,但请求处理里直接修改了单例上可变、跨请求共享的状态(最典型是model.generation_config),一次请求的改动持久化污染后续所有请求。修复三层次:第一层绝不修改单例可变状态,生成参数一律generate(**kwargs)传入;第二层用SergeModelSingletondataclass 安全复用单例、请求参数基于不可变模板副本隔离;第三层用 pytest 守「单例只加载一次」「不污染默认」「不串味」。
工程启示:模型单例化是合理优化,但单例身上的任何可变状态都是跨请求污染的火药桶。生成参数、对话历史、缓存输入,一律「按请求隔离、传参不修改原式」。记住:共享可变状态 + 多请求 = 迟早串味。