LangChain 生产发布检查表:可执行预检、故障注入与并发会话实测
发布检查表通常是一份文档:依赖锁了吗、密钥没进仓库吧、超时设了吗、会话隔离做了吗。每一项打勾的人都很认真,但文档里的勾选没有被任何东西验证过,等到线上出事,才发现“超时设了”其实没设,“会话隔离”在并发下并不成立。
本页写给要把 LangChain v1 应用推到生产环境的开发者。做法是把检查表改写成一个可以放进 CI 的预检脚本,并且对每一项检查都注入对应的故障,确认它真的会失败。最后单独实测一个文档没有强调、却最容易在生产里出事的话题:并发下的会话状态。
本页没有测试什么
本页没有部署任何东西,也没有连接任何真实服务。 预检对象是脚本生成的一个很小的示例应用,模型是脚本化的替身。因此以下内容不在本页范围:容器与编排平台的发布流程、依赖漏洞扫描、专用的密钥扫描工具、真实模型服务的行为、Postgres 等外部 checkpointer、异步调用路径(AsyncSqliteSaver 等)、多进程与多实例部署下的会话并发控制(本页的锁只在单进程内有效),以及监控告警和回滚演练这类必须由人执行的环节。
预检通过只说明“这些具体的检查没有发现问题”,不是对应用质量的担保。
适用版本与验证信息
| 项目 | 内容 |
|---|---|
| 验证日期 | 2026-10-26 |
| Python | 3.12.12 |
| 包版本 | langchain==1.4.7、langchain-core==1.7.0、langchain-openai==1.7.2、langgraph==1.2.17、langgraph-checkpoint==4.3.0、langgraph-checkpoint-sqlite==3.1.2 |
| 被测对象 | 站点仓库 labs/production-release-checklist/project_factory.py 生成的示例应用;create_agent + InMemorySaver / SqliteSaver;模型是脚本化的 EchoModel,不联网、不需要密钥 |
| 官方来源 | Checkpointers (opens in a new tab)、Persistence (opens in a new tab)、Interrupts (opens in a new tab),对照官方文档仓库 langchain-ai/docs (opens in a new tab) 提交 b8d4ced(2026-10-16);SQLite 的线程规则见 Python sqlite3 文档 (opens in a new tab) |
| 实验代码 | 站点仓库 labs/production-release-checklist/,依赖锁定在 labs/requirements/v1.txt;超时预算复用 labs/model-call-latency-budget/budget.py |
先说结论
- 7 项检查,11 个故障注入,每个故障恰好被它对应的那一项检查拦下,干净的项目全部通过。 没有被注入过故障的检查,不能算检查(实验 2)。
- 默认的
ChatOpenAI()会被超时预算检查拦下。 它没有超时,最坏耗时没有上界;把timeout设成 60 秒但保持默认重试,最坏耗时是 181.5 秒,同样超过 60 秒的限制(实验 2,原理见模型调用的延迟预算)。 - 所有用户共用一个常量
thread_id,第二个用户就会拿到第一个用户的内容。 顺序调用就能复现:u1的回复里带着tok-u0(实验 2)。 - 对同一个
thread_id同时发 5 条消息,不会报错,但最终的会话历史里只剩 1 条。 5 个调用都正常返回,其余 4 条用户消息从历史里静默消失;加上每个会话一把锁以后 5 条全部保留(实验 3)。 SqliteSaver的默认连接在线程池里 20 个调用全部失败,报ProgrammingError;用check_same_thread=False创建连接后正常。官方文档把 SQLite checkpointer 定位为适合实验和本地流程,它是否适合你的并发量要自己评估(实验 3)。- 预检只覆盖能自动判断的部分。 回滚演练、告警是否送达、Owner 授权这些仍要人来做(上线检查表一节)。
实验 0:示例应用与测试替身
project_factory.py 生成一个很小的应用:一个 build_llm() 创建 ChatOpenAI,一个 Service 按用户分配 thread_id、按会话加锁并调用 Agent,外加依赖文件、回滚锁文件和 .gitignore。每个参数对应一种发布故障,不传参数就是“应有的样子”:
"""Write a tiny fixture application, optionally with one injected release fault.
The clean project is what a release candidate should look like: pinned dependencies that match the
environment, a rollback lock, no legacy imports, no secrets in the tree, a bounded model client,
one conversation thread per user and one lock per thread.
"""
from importlib.metadata import version
from pathlib import Path
PINNED = ["langchain", "langchain-core", "langchain-openai", "langgraph"]
APP = '''"""Fixture service: one chat endpoint with a conversation thread per user."""
import contextlib
import os
import threading
from collections import defaultdict
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver
def build_llm():
return ChatOpenAI(model="gpt-test", api_key=os.environ.get("OPENAI_API_KEY", "unset"){llm_kwargs})
def build_agent(model=None):
return create_agent(model or build_llm(), tools=[], checkpointer=InMemorySaver())
class Service:
def __init__(self, agent):
self.agent = agent
self._locks = defaultdict(threading.Lock)
def _config(self, user_id):
return {{"configurable": {{"thread_id": {thread_id}}}}}
def handle(self, user_id, text):
guard = {guard}
with guard:
result = self.agent.invoke({{"messages": [HumanMessage(text)]}}, self._config(user_id))
return result["messages"][-1].content
def history(self, user_id):
state = self.agent.get_state(self._config(user_id))
return [m.content for m in state.values.get("messages", []) if isinstance(m, HumanMessage)]
'''
def write_project(root: Path, *, pins=None, previous_lock=True, legacy_import=False, secret=None,
env_file=False, ignore_env=True, llm_kwargs=", timeout=20, max_retries=1", shared_thread=False,
use_lock=True) -> None:
root.mkdir(parents=True, exist_ok=True)
lines = pins if pins is not None else [f"{name}=={version(name)}" for name in PINNED]
(root / "requirements.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
if previous_lock:
(root / "requirements.previous.txt").write_text("langchain==0.3.27\nlangchain-core==0.3.76\n", encoding="utf-8")
ignored = [".venv/", ".env", "__pycache__/"] if ignore_env else [".venv/", "__pycache__/"]
(root / ".gitignore").write_text("\n".join(ignored) + "\n", encoding="utf-8")
(root / ".env.example").write_text("OPENAI_API_KEY=\n", encoding="utf-8")
thread_id = '"default"' if shared_thread else "user_id"
guard = "self._locks[" + thread_id + "]" if use_lock else "contextlib.nullcontext()"
source = APP.format(llm_kwargs=llm_kwargs, thread_id=thread_id, guard=guard)
if secret:
source += f'\nOPENAI_API_KEY = "{secret}"\n'
(root / "app.py").write_text(source, encoding="utf-8")
if legacy_import:
(root / "legacy_module.py").write_text("from langchain.chains import LLMChain\n", encoding="utf-8")
if env_file:
(root / ".env").write_text("OPENAI_API_KEY=placeholder\n", encoding="utf-8")EchoModel 把它收到的所有用户消息原样回显。回复里出现了调用方没有发送过的消息,就说明读到了别人的历史,不需要依赖任何真实模型:
"""A scripted chat model: it answers with every human message it was given, after a short delay.
The reply makes conversation state visible: if a reply contains a message the caller never sent,
that message came from somebody else's history. No network and no credentials are involved.
"""
import time
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.outputs import ChatGeneration, ChatResult
class EchoModel(BaseChatModel):
delay: float = 0.0
@property
def _llm_type(self) -> str:
return "echo"
def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult:
time.sleep(self.delay)
humans = [m.content for m in messages if isinstance(m, HumanMessage)]
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="seen:" + ",".join(humans)))])
def bind_tools(self, tools, **kwargs):
return self实验 1:把检查表写成 7 项可执行检查
preflight.py 接收一个项目目录,依次运行 7 项检查,全部通过时退出码为 0。每项检查返回的信息会指出第一个出问题的位置,方便直接修:
"""Release preflight: seven executable checks over a project directory.
usage: python preflight.py <project-dir> [--limit SECONDS]
Exit code 0 when every check passes, 1 otherwise. Each check returns (ok, message); the message
names the first offending item so a failing release can be fixed without reading the script.
"""
import ast
import importlib
import importlib.util
import re
import sys
import uuid
from concurrent.futures import ThreadPoolExecutor
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "model-call-latency-budget")) # reuse the worst-case latency model
from budget import latency_range # noqa: E402
from doubles import EchoModel # noqa: E402
PIN = re.compile(r"^([A-Za-z0-9_.\-]+)==([^=\s;]+)$")
SECRET_PATTERNS = {
"OpenAI-style key": re.compile(r"sk-[A-Za-z0-9_\-]{16,}"),
"AWS access key id": re.compile(r"AKIA[0-9A-Z]{16}"),
"private key block": re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
}
def requirement_lines(path: Path) -> list[str]:
return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip() and not line.startswith("#")]
def check_pins(root: Path, limit: float):
for line in requirement_lines(root / "requirements.txt"):
match = PIN.match(line)
if not match:
return False, f"not pinned with '==': {line}"
name, pinned = match.groups()
try:
installed = version(name)
except PackageNotFoundError:
return False, f"{name} is pinned but not installed"
if installed != pinned:
return False, f"{name}: pinned {pinned}, installed {installed}"
return True, "all requirements pinned and equal to the installed versions"
def check_rollback_lock(root: Path, limit: float):
previous = root / "requirements.previous.txt"
if not previous.exists():
return False, "requirements.previous.txt is missing"
for line in requirement_lines(previous):
if not PIN.match(line):
return False, f"rollback lock not pinned: {line}"
return True, "rollback lock present and fully pinned"
def resolves(module: str, name: str) -> bool:
try:
return hasattr(importlib.import_module(module), name)
except ImportError:
return False
def check_imports_resolve(root: Path, limit: float):
"""Every langchain/langgraph import in the project must resolve in the release environment."""
for path in sorted(root.rglob("*.py")):
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
if isinstance(node, ast.ImportFrom) and node.module and node.module.split(".")[0].startswith(("langchain", "langgraph")):
for alias in node.names:
if not resolves(node.module, alias.name):
return False, f"{path.name}:{node.lineno} cannot import {alias.name} from {node.module}"
return True, "every langchain/langgraph import resolves in this environment"
def check_secrets(root: Path, limit: float):
gitignore = root / ".gitignore"
if not gitignore.exists() or ".env" not in gitignore.read_text(encoding="utf-8").split():
return False, ".gitignore does not list .env"
if (root / ".env").exists():
return False, ".env is present in the release tree"
for path in sorted(root.rglob("*")):
if path.is_file() and path.suffix in {".py", ".txt", ".md", ".yaml", ".yml", ".json", ".toml", ".example"}:
text = path.read_text(encoding="utf-8", errors="ignore")
for label, pattern in SECRET_PATTERNS.items():
if pattern.search(text):
return False, f"{label} found in {path.name}"
return True, ".env ignored and absent; no key-like strings in source files"
def load_app(root: Path):
spec = importlib.util.spec_from_file_location(f"fixture_app_{uuid.uuid4().hex}", root / "app.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def check_call_budget(root: Path, limit: float):
llm = load_app(root).build_llm()
timeout = llm.request_timeout
if timeout is None:
return False, "no timeout: the worst case of one failing call is unbounded"
per_attempt = max(timeout) if isinstance(timeout, tuple) else float(timeout)
retries = 2 if llm.max_retries is None else llm.max_retries # None means the SDK default of 2
_, worst = latency_range(per_attempt, retries)
if worst > limit:
return False, f"worst case {worst:.1f}s exceeds the {limit:.0f}s limit (timeout={per_attempt:g}, max_retries={retries})"
return True, f"worst case {worst:.1f}s within the {limit:.0f}s limit (timeout={per_attempt:g}, max_retries={retries})"
def check_session_isolation(root: Path, limit: float):
app = load_app(root)
service = app.Service(app.build_agent(EchoModel()))
for user in ("u0", "u1", "u2"):
reply = service.handle(user, f"tok-{user}")
if reply != f"seen:tok-{user}":
return False, f"{user} received a reply built from other messages: {reply}"
return True, "three users, three separate conversation histories"
def check_same_session_serialization(root: Path, limit: float):
app = load_app(root)
service = app.Service(app.build_agent(EchoModel(delay=0.05)))
with ThreadPoolExecutor(5) as pool:
list(pool.map(lambda i: service.handle("alice", f"m{i}"), range(5)))
kept = len(service.history("alice"))
if kept != 5:
return False, f"5 concurrent messages to one thread, only {kept} kept in its history"
return True, "5 concurrent messages to one thread, all 5 kept"
CHECKS = {
"pins": check_pins,
"rollback_lock": check_rollback_lock,
"imports_resolve": check_imports_resolve,
"secrets": check_secrets,
"call_budget": check_call_budget,
"session_isolation": check_session_isolation,
"same_session_serialization": check_same_session_serialization,
}
def run_checks(root: Path, limit: float = 60.0) -> dict[str, tuple[bool, str]]:
return {name: check(root, limit) for name, check in CHECKS.items()}
def main(argv: list[str]) -> int:
if not argv or argv[0].startswith("--"):
print(__doc__)
return 2
limit = float(argv[argv.index("--limit") + 1]) if "--limit" in argv else 60.0
results = run_checks(Path(argv[0]), limit)
for name, (ok, message) in results.items():
print(f"{'PASS' if ok else 'FAIL'} {name}: {message}")
return 0 if all(ok for ok, _ in results.values()) else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))先看它在两个项目上的实际输出,一个干净,一个同时有“依赖没锁”和“模型没超时”两个故障:
"""Run the preflight CLI entry point on a clean project and on a project with two faults."""
import tempfile
from importlib.metadata import version
from pathlib import Path
from preflight import main
from project_factory import PINNED, write_project
pins = [f"{name}=={version(name)}" for name in PINNED]
cases = [
("clean release candidate", {}),
("release candidate with two faults", {"pins": ["langchain>=1.0"] + pins[1:], "llm_kwargs": ""}),
]
for label, faults in cases:
with tempfile.TemporaryDirectory() as tmp:
write_project(Path(tmp), **faults)
print(f"$ python preflight.py <{label}>")
code = main([tmp])
print(f"exit code: {code}\n")$ python preflight.py <clean release candidate>
PASS pins: all requirements pinned and equal to the installed versions
PASS rollback_lock: rollback lock present and fully pinned
PASS imports_resolve: every langchain/langgraph import resolves in this environment
PASS secrets: .env ignored and absent; no key-like strings in source files
PASS call_budget: worst case 40.5s within the 60s limit (timeout=20, max_retries=1)
PASS session_isolation: three users, three separate conversation histories
PASS same_session_serialization: 5 concurrent messages to one thread, all 5 kept
exit code: 0
$ python preflight.py <release candidate with two faults>
FAIL pins: not pinned with '==': langchain>=1.0
PASS rollback_lock: rollback lock present and fully pinned
PASS imports_resolve: every langchain/langgraph import resolves in this environment
PASS secrets: .env ignored and absent; no key-like strings in source files
FAIL call_budget: no timeout: the worst case of one failing call is unbounded
PASS session_isolation: three users, three separate conversation histories
PASS same_session_serialization: 5 concurrent messages to one thread, all 5 kept
exit code: 1几点实现上的取舍:
- 依赖检查比较的是“锁定值”和“已安装值”。 只检查“有没有写
==”挡不住“文件里写的是旧版本、环境里装的是新版本”这类漂移,所以两者都比。 - 导入检查看的是“能不能导入”,而不是匹配一张旧符号清单。 在发布环境里逐个解析项目对
langchain与langgraph的导入,旧版的langchain.chains等路径在 v1 里不存在,自然失败。这样不需要维护一份会过期的黑名单;符号对照见导入兼容矩阵。 - 超时预算检查只读公开属性。 取
request_timeout和max_retries(None按 SDK 默认的 2 次重试计算),再套用延迟预算页的最坏耗时模型,限制默认 60 秒,可用--limit调整。 - 两项并发检查都在进程内实际调用服务。 一项顺序访问三个用户,检查是否串会话;另一项对同一用户并发发 5 条消息,检查历史是否完整。
实验 2:检查要先被证伪,再被信任
inject_faults.py 每次只注入一个故障,运行全部检查,并断言“恰好这一项失败,其他项都通过”。只有这样,才能说明每项检查对它负责的那类问题是有反应的,也没有误伤其他项:
"""Check the checks: inject one release fault at a time and see which preflight check reacts.
A preflight that has never failed proves nothing. Each fault below must be caught by exactly the
check named next to it, and the clean project must pass everything.
"""
import sys
import tempfile
from importlib.metadata import version
from pathlib import Path
from preflight import CHECKS, run_checks
from project_factory import PINNED, write_project
CLEAN = [f"{name}=={version(name)}" for name in PINNED]
UNPINNED = ["langchain>=1.0"] + CLEAN[1:]
DRIFTED = [CLEAN[0], "langchain-core==0.3.0"] + CLEAN[2:]
FAULTS = [
("unpinned dependency", {"pins": UNPINNED}, "pins"),
("pin differs from installed version", {"pins": DRIFTED}, "pins"),
("no rollback lock", {"previous_lock": False}, "rollback_lock"),
("legacy import langchain.chains", {"legacy_import": True}, "imports_resolve"),
("hard-coded API key in app.py", {"secret": "sk-test-0123456789abcdef"}, "secrets"),
(".env left in the release tree", {"env_file": True}, "secrets"),
(".gitignore does not list .env", {"ignore_env": False}, "secrets"),
("model client without timeout", {"llm_kwargs": ""}, "call_budget"),
("timeout=60 with default retries", {"llm_kwargs": ", timeout=60"}, "call_budget"),
("one shared thread_id for all users", {"shared_thread": True}, "session_isolation"),
("no per-thread lock", {"use_lock": False}, "same_session_serialization"),
]
def evaluate(**faults) -> dict:
with tempfile.TemporaryDirectory() as tmp:
write_project(Path(tmp), **faults)
return run_checks(Path(tmp))
def main() -> int:
mismatches = 0
results = evaluate()
clean_ok = all(ok for ok, _ in results.values())
print(f"{'clean project':<38} -> {'all ' + str(len(CHECKS)) + ' checks pass' if clean_ok else 'UNEXPECTED FAILURE'}")
mismatches += 0 if clean_ok else 1
for label, faults, expected in FAULTS:
results = evaluate(**faults)
failed = [name for name, (ok, _) in results.items() if not ok]
verdict = "" if failed == [expected] else f" <-- expected only {expected}"
mismatches += 1 if failed != [expected] else 0
detail = "; ".join(f"FAIL {name}: {results[name][1]}" for name in failed) or "no check failed"
print(f"{label:<38} -> {detail}{verdict}")
print(f"faults injected: {len(FAULTS)}, caught by exactly the expected check: {len(FAULTS) - mismatches}")
return 1 if mismatches else 0
if __name__ == "__main__":
sys.exit(main())clean project -> all 7 checks pass
unpinned dependency -> FAIL pins: not pinned with '==': langchain>=1.0
pin differs from installed version -> FAIL pins: langchain-core: pinned 0.3.0, installed 1.7.0
no rollback lock -> FAIL rollback_lock: requirements.previous.txt is missing
legacy import langchain.chains -> FAIL imports_resolve: legacy_module.py:1 cannot import LLMChain from langchain.chains
hard-coded API key in app.py -> FAIL secrets: OpenAI-style key found in app.py
.env left in the release tree -> FAIL secrets: .env is present in the release tree
.gitignore does not list .env -> FAIL secrets: .gitignore does not list .env
model client without timeout -> FAIL call_budget: no timeout: the worst case of one failing call is unbounded
timeout=60 with default retries -> FAIL call_budget: worst case 181.5s exceeds the 60s limit (timeout=60, max_retries=2)
one shared thread_id for all users -> FAIL session_isolation: u1 received a reply built from other messages: seen:tok-u0,tok-u1
no per-thread lock -> FAIL same_session_serialization: 5 concurrent messages to one thread, only 1 kept in its history
faults injected: 11, caught by exactly the expected check: 11逐行解读:
- 依赖:写成
langchain>=1.0被判为未锁定;锁成langchain-core==0.3.0而环境里装的是 1.7.0,被判为漂移。两种问题报的是同一项检查,但信息不同。 - 回滚锁:没有
requirements.previous.txt,出事时就没有可以回退的明确版本。 - 导入:
from langchain.chains import LLMChain在 v1 环境里无法导入,检查报出具体文件与行号。 - 密钥:三种情形各自被拦下——源码里写死了类密钥字符串、
.env文件留在发布目录、.gitignore没有忽略.env。这里的正则只覆盖几种常见格式,不能替代专用扫描工具。 - 超时预算:没有超时的客户端最坏耗时没有上界;
timeout=60但保持默认重试,最坏 181.5 秒,超过 60 秒的限制。 - 串会话:所有用户共用同一个
thread_id,第二个用户u1的回复里带着第一个用户的消息tok-u0。 - 丢更新:没有按会话加锁时,对同一会话并发发 5 条消息,历史里只剩 1 条。这个现象在下一个实验里单独展开。
最后一行的计数是预检本身的验收:11 个故障全部被预期的检查拦下。以后给预检新增一项检查,就要同时新增一个注入它的故障。
实验 3:并发下的会话状态
最后两个故障来自同一个问题:LangGraph 的 checkpointer 保存状态,但不会替你把同一个 thread_id 上的并发调用排队。 concurrency_facts.py 把这个问题和两个相关的坑一起测了:
"""Conversation state under concurrency: checkpointers, thread_id and the same-thread race."""
import sqlite3
import tempfile
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from doubles import EchoModel
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.sqlite import SqliteSaver
def distinct_users(checkpointer, users: int = 20):
"""Each user has its own thread_id. Returns (replies built from foreign messages, error classes)."""
agent = create_agent(EchoModel(delay=0.05), tools=[], checkpointer=checkpointer)
def one(i: int):
try:
reply = agent.invoke({"messages": [HumanMessage(f"tok{i}")]}, {"configurable": {"thread_id": f"user-{i}"}})
return None if reply["messages"][-1].content == f"seen:tok{i}" else "leak"
except Exception as exc:
return type(exc).__name__
with ThreadPoolExecutor(users) as pool:
outcomes = list(pool.map(one, range(users)))
return outcomes.count("leak"), sorted({o for o in outcomes if o and o != "leak"}), sum(o not in (None, "leak") for o in outcomes)
def same_thread(checkpointer, locked: bool, calls: int = 5) -> int:
"""Send `calls` messages to one thread at the same time; return how many human messages survive."""
agent = create_agent(EchoModel(delay=0.05), tools=[], checkpointer=checkpointer)
config = {"configurable": {"thread_id": "alice"}}
lock = threading.Lock()
def one(i: int) -> None:
if locked:
with lock:
agent.invoke({"messages": [HumanMessage(f"m{i}")]}, config)
else:
agent.invoke({"messages": [HumanMessage(f"m{i}")]}, config)
with ThreadPoolExecutor(calls) as pool:
list(pool.map(one, range(calls)))
messages = agent.get_state(config).values["messages"]
return sum(isinstance(m, HumanMessage) for m in messages)
tmp = Path(tempfile.mkdtemp())
print("20 users at once, one thread_id each")
leaks, kinds, errors = distinct_users(InMemorySaver())
print(f" InMemorySaver leaked replies: {leaks}, failed calls: {errors}")
leaks, kinds, errors = distinct_users(SqliteSaver(sqlite3.connect(tmp / "default.db")))
print(f" SqliteSaver, default connection leaked replies: {leaks}, failed calls: {errors} {kinds}")
leaks, kinds, errors = distinct_users(SqliteSaver(sqlite3.connect(tmp / "shared.db", check_same_thread=False)))
print(f" SqliteSaver, check_same_thread=False leaked replies: {leaks}, failed calls: {errors}")
print("5 messages to ONE thread_id at the same time")
kept = same_thread(InMemorySaver(), locked=False)
print(f" InMemorySaver, no lock human messages kept: {kept} of 5")
kept = same_thread(SqliteSaver(sqlite3.connect(tmp / "race.db", check_same_thread=False)), locked=False)
print(f" SqliteSaver, no lock human messages kept: {kept} of 5")
kept = same_thread(InMemorySaver(), locked=True)
print(f" InMemorySaver, one lock per thread human messages kept: {kept} of 5")20 users at once, one thread_id each
InMemorySaver leaked replies: 0, failed calls: 0
SqliteSaver, default connection leaked replies: 0, failed calls: 20 ['ProgrammingError']
SqliteSaver, check_same_thread=False leaked replies: 0, failed calls: 0
5 messages to ONE thread_id at the same time
InMemorySaver, no lock human messages kept: 1 of 5
SqliteSaver, no lock human messages kept: 1 of 5
InMemorySaver, one lock per thread human messages kept: 5 of 5- 不同用户、不同
thread_id:隔离成立。 20 个用户同时调用,没有一次拿到别人的内容。在这个实验的范围内,InMemorySaver多线程使用没有出现串内容或报错。 SqliteSaver的默认连接不能跨线程使用。 用sqlite3.connect(path)的默认参数创建连接,在线程池里 20 个调用全部失败,错误是 Python 的sqlite3.ProgrammingError(连接只能在创建它的线程里使用)。Web 框架的同步接口通常运行在线程池里,会直接踩到。官方文档的 Interrupts 页面示例里创建连接时就传了check_same_thread=False。传了以后 20 个调用全部正常、没有串内容。- 同一个
thread_id的并发调用会静默丢更新。 5 条消息同时发给同一个会话,InMemorySaver与SqliteSaver都只保留了 1 条用户消息,而且没有报任何错误:每个调用都正常返回。这符合“每次运行从自己开始时读到的状态出发,后写入的检查点覆盖先写入的”这一解释,但本页只验证了现象,没有逐层验证内部机制。用户连续点两次发送、前端重试、消息队列重复投递,都会触发这种并发。 - 每个会话一把锁就够了,不需要全局锁。 同一个会话的调用串行以后,5 条消息全部保留;不同会话之间仍然并行。预检里的
Service用的就是这种做法。
要注意这把锁只在单个进程里有效。多实例部署时,同一个会话的两个请求可能落在不同的进程上,本页没有测试这种情形;需要用外部存储自带的并发控制,或者在入口处按会话路由或排队,再用本页的并发检查针对你的实际方案重新验证。
上线检查表:哪些能自动化,哪些必须人来做
| 检查项 | 自动化 | 预检里的实现 | 被注入的故障 | 仍需人工补充 |
|---|---|---|---|---|
| 依赖锁定与漂移 | 是 | pins | 未锁定、版本不一致 | 升级前评估变更日志 |
| 回滚用的上一版锁文件 | 是 | rollback_lock | 缺少锁文件 | 在预发环境真的回滚一次并验证 |
| 导入与弃用符号 | 是 | imports_resolve | 旧版导入路径 | 弃用警告的处理策略 |
| 密钥与环境文件 | 部分 | secrets | 写死密钥、.env 遗留、未忽略 | 轮换策略、专用扫描工具、历史提交审计 |
| 模型调用的超时与重试 | 是 | call_budget | 无超时、超限 | 线上延迟分布监控 |
| 会话隔离 | 是 | session_isolation | 共用 thread_id | 多实例下的路由 |
| 同一会话的并发 | 是 | same_session_serialization | 缺少会话锁 | 多实例下的分布式方案 |
| 追踪与脱敏 | 否 | 不在预检内,见LangSmith 追踪上线前核对 | 无 | 按该页的做法,用本地接收端核对实际外发的内容 |
| 告警是否真的送达 | 否 | 无 | 无 | 人工触发一次测试告警 |
| 事故演练 | 否 | 无 | 无 | 按故障诊断中心演练常见报错 |
| 发布授权与上线窗口 | 否 | 无 | 无 | Owner 确认 |
把它用在你的系统上
- 把
project_factory.py换成你的真实项目。 预检直接读取项目目录;build_llm()、build_agent()、Service这三个入口按你的应用命名做相应调整,保持“返回客户端”“用替身模型创建 Agent”“按用户调用并读取历史”这三种能力即可。 - 在 CI 里运行
python preflight.py <项目目录> --limit <秒>。 任何一项失败,退出码都是 1,发布流水线据此中止。 - 每新增一项检查,就在
inject_faults.py里加一个对应的故障。 没有被证伪的检查不要合入。 - 预检失败的信息要能直接指向修复。 本页的检查都带文件名、行号或具体数值,写你自己的检查时保持这一点。
- 并发方案以自己的部署形态为准。 单进程多线程可以照搬会话锁;多实例要先确定会话路由或外部锁,再用
same_session_serialization的写法验证。
故障树与排障:发布前后出现问题怎么查
| 现象 | 最可能的原因 | 怎么验证 | 处理 |
|---|---|---|---|
| 同一个用户快速连发后,对话里少了几句 | 同一 thread_id 并发调用,后写入的检查点覆盖了前面的 | 对同一会话并发发消息,读取 get_state 看保留的消息数(实验 3) | 入口处按会话串行化,或做幂等去重 |
| 用户看到别人的对话内容 | 多个用户共用同一个 thread_id,或把用户标识误写成常量 | 顺序访问两个用户,检查回复是否带对方的消息(实验 2) | 用稳定且不可猜测的用户或会话标识生成 thread_id |
开发环境正常,部署后所有请求都报 ProgrammingError | SqliteSaver 的连接在别的线程里使用 | 看错误信息是否提到“同一个线程”(实验 3) | 创建连接时传 check_same_thread=False,并评估 SQLite 是否适合你的并发量 |
| 预检通过,上线后仍然偶发长时间卡住 | 预检只算了配置自身的上界,不含提供方的 Retry-After 和排队 | 对照线上延迟分布与预检限制 | 见模型调用的延迟预算 |
| 升级依赖后预检的导入检查失败 | 旧路径在新版本里被移除 | 看失败信息中的文件、行号和符号 | 对照导入兼容矩阵迁移,或使用迁移 Runbook 里的兼容层 |
| 需要回滚却发现没有可用的上一版 | 没有保存上一版的锁定依赖 | 看 rollback_lock 是否通过 | 每次发布前把当前线上版本的锁文件另存为 requirements.previous.txt |
相关阅读
- 从旧版升级到 v1 的完整步骤与回滚方案:LangChain 从 v0.x 升级到 v1:双轨迁移 Runbook;
thread_id与会话历史的更多实测,包括忘记配置 checkpointer 时的静默失忆:ConversationBufferMemory 迁移到 v1;- 崩溃、重启与重放时 checkpoint 的行为:LangGraph checkpoint 与故障恢复;
- 模型调用的最坏耗时与重试叠加:模型调用的延迟预算;
- 打开追踪之前的外发与脱敏核对:LangSmith 追踪上线前核对;
- 线上报错对号入座:LangChain 生产故障诊断中心。
来源
- Checkpointers (opens in a new tab):官方把 SQLite 实现定位为适合实验和本地流程,并列出异步版本的 Sqlite 与 Postgres checkpointer;
- Persistence (opens in a new tab):
thread_id与检查点的基本概念,SqliteSaver定位为开发用的本地文件存储;本页核对的 Checkpointers 与 Persistence 两页中没有检索到关于同一thread_id并发调用的说明,因此丢更新的结论来自本页实验,而不是官方承诺; - Interrupts (opens in a new tab):示例中创建 SQLite 连接时使用
check_same_thread=False; - Python sqlite3 文档 (opens in a new tab):
check_same_thread参数的含义。
以上来源均于 2026-10-26 核对。本页所有输出来自站点仓库 labs/production-release-checklist/ 在上表版本下的实际运行;超时预算复用了 labs/model-call-latency-budget/ 的计算模型。