langgraph-checkpoint-plainredis 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langgraph_checkpoint_plainredis-0.1.0/.gitattributes +12 -0
- langgraph_checkpoint_plainredis-0.1.0/.github/workflows/ci.yml +60 -0
- langgraph_checkpoint_plainredis-0.1.0/.gitignore +20 -0
- langgraph_checkpoint_plainredis-0.1.0/LICENSE +21 -0
- langgraph_checkpoint_plainredis-0.1.0/PKG-INFO +334 -0
- langgraph_checkpoint_plainredis-0.1.0/README.en.md +275 -0
- langgraph_checkpoint_plainredis-0.1.0/README.md +282 -0
- langgraph_checkpoint_plainredis-0.1.0/examples/basic.py +77 -0
- langgraph_checkpoint_plainredis-0.1.0/examples/basic_sync.py +79 -0
- langgraph_checkpoint_plainredis-0.1.0/pyproject.toml +61 -0
- langgraph_checkpoint_plainredis-0.1.0/src/langgraph_checkpoint_plainredis/__init__.py +6 -0
- langgraph_checkpoint_plainredis-0.1.0/src/langgraph_checkpoint_plainredis/py.typed +0 -0
- langgraph_checkpoint_plainredis-0.1.0/src/langgraph_checkpoint_plainredis/saver.py +1035 -0
- langgraph_checkpoint_plainredis-0.1.0/tests/__init__.py +0 -0
- langgraph_checkpoint_plainredis-0.1.0/tests/conftest.py +121 -0
- langgraph_checkpoint_plainredis-0.1.0/tests/test_edge.py +293 -0
- langgraph_checkpoint_plainredis-0.1.0/tests/test_graph_e2e.py +146 -0
- langgraph_checkpoint_plainredis-0.1.0/tests/test_parity_memory.py +223 -0
- langgraph_checkpoint_plainredis-0.1.0/tests/test_sync_saver.py +254 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# 统一 LF:库里 + 工作区都用 LF(纯 Python 包,主要在 Linux/WSL 侧维护)喵~
|
|
2
|
+
# 目的:不依赖本机 core.autocrlf(Git for Windows 常设为 true,会把工作区写成 CRLF),
|
|
3
|
+
# 避免文件在 LF/CRLF 之间来回跳导致"整个文件都变了"的假 diff。喵~
|
|
4
|
+
* text=auto eol=lf
|
|
5
|
+
|
|
6
|
+
# 二进制:不做任何换行转换
|
|
7
|
+
*.png binary
|
|
8
|
+
*.jpg binary
|
|
9
|
+
*.jpeg binary
|
|
10
|
+
*.gif binary
|
|
11
|
+
*.zip binary
|
|
12
|
+
*.whl binary
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
name: py${{ matrix.python-version }} / redis${{ matrix.redis-version }}
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
16
|
+
redis-version: ["5", "7", "8"]
|
|
17
|
+
|
|
18
|
+
# A plain redis image: no RedisJSON, no RediSearch, nothing but the core
|
|
19
|
+
# command set. That is exactly the point of this package.
|
|
20
|
+
services:
|
|
21
|
+
redis:
|
|
22
|
+
image: redis:${{ matrix.redis-version }}
|
|
23
|
+
ports:
|
|
24
|
+
- 6379:6379
|
|
25
|
+
options: >-
|
|
26
|
+
--health-cmd "redis-cli ping"
|
|
27
|
+
--health-interval 5s
|
|
28
|
+
--health-timeout 3s
|
|
29
|
+
--health-retries 10
|
|
30
|
+
|
|
31
|
+
env:
|
|
32
|
+
PLAINREDIS_TEST_URL: redis://127.0.0.1:6379/15
|
|
33
|
+
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/checkout@v4
|
|
36
|
+
|
|
37
|
+
- uses: actions/setup-python@v5
|
|
38
|
+
with:
|
|
39
|
+
python-version: ${{ matrix.python-version }}
|
|
40
|
+
|
|
41
|
+
- name: Install
|
|
42
|
+
run: |
|
|
43
|
+
python -m pip install --upgrade pip
|
|
44
|
+
pip install -e ".[dev]"
|
|
45
|
+
|
|
46
|
+
- name: Show that the server has no modules loaded
|
|
47
|
+
run: |
|
|
48
|
+
sudo apt-get update -qq && sudo apt-get install -y -qq redis-tools
|
|
49
|
+
echo "--- MODULE LIST ---"
|
|
50
|
+
redis-cli -h 127.0.0.1 MODULE LIST
|
|
51
|
+
echo "--- INFO server ---"
|
|
52
|
+
redis-cli -h 127.0.0.1 INFO server | grep -E "redis_version|redis_mode"
|
|
53
|
+
|
|
54
|
+
- name: Test
|
|
55
|
+
run: pytest -v
|
|
56
|
+
|
|
57
|
+
- name: Build wheel
|
|
58
|
+
run: |
|
|
59
|
+
pip install build
|
|
60
|
+
python -m build
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Agolw (Jinzhi Deng / 邓金枝)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langgraph-checkpoint-plainredis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangGraph checkpoint savers on plain Redis (sync + async) - no RedisJSON / RediSearch modules required, works on Redis 5+
|
|
5
|
+
Project-URL: Homepage, https://github.com/Agolw/langgraph-checkpoint-plainredis
|
|
6
|
+
Project-URL: Repository, https://github.com/Agolw/langgraph-checkpoint-plainredis
|
|
7
|
+
Project-URL: Issues, https://github.com/Agolw/langgraph-checkpoint-plainredis/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Agolw/langgraph-checkpoint-plainredis/releases
|
|
9
|
+
Author: Agolw
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 Agolw (Jinzhi Deng / 邓金枝)
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: agent,checkpoint,langchain,langgraph,memory,persistence,redis
|
|
33
|
+
Classifier: Development Status :: 3 - Alpha
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Operating System :: OS Independent
|
|
37
|
+
Classifier: Programming Language :: Python :: 3
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
42
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
43
|
+
Classifier: Typing :: Typed
|
|
44
|
+
Requires-Python: >=3.10
|
|
45
|
+
Requires-Dist: langgraph-checkpoint<5,>=4.1
|
|
46
|
+
Requires-Dist: redis>=5.0
|
|
47
|
+
Provides-Extra: dev
|
|
48
|
+
Requires-Dist: langgraph<2,>=1.0; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
50
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
51
|
+
Description-Content-Type: text/markdown
|
|
52
|
+
|
|
53
|
+
# langgraph-checkpoint-plainredis
|
|
54
|
+
|
|
55
|
+
[](https://github.com/Agolw/langgraph-checkpoint-plainredis/actions/workflows/ci.yml)
|
|
56
|
+
[](https://pypi.org/project/langgraph-checkpoint-plainredis/)
|
|
57
|
+
[](#兼容性)
|
|
58
|
+
[](LICENSE)
|
|
59
|
+
|
|
60
|
+
> English: [README.en.md](README.en.md)
|
|
61
|
+
|
|
62
|
+
**基于「裸 Redis」的 LangGraph checkpointer(异步 + 同步两个类)—— 不需要 RedisJSON、不需要 RediSearch,Redis 5.0+ 即可运行。**
|
|
63
|
+
|
|
64
|
+
用它可以把 LangGraph Agent 的状态(多会话 thread、checkpoint、pending writes、时间旅行)持久化到
|
|
65
|
+
一个**只提供核心命令集**的 Redis 里:不装模块、不上 Redis Stack、不要求 Redis 8。
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from langgraph_checkpoint_plainredis import AsyncRedisSaver # graph.ainvoke()
|
|
69
|
+
from langgraph_checkpoint_plainredis import RedisSaver # graph.invoke()
|
|
70
|
+
|
|
71
|
+
saver = AsyncRedisSaver(url="redis://127.0.0.1:6379/0", ttl=7 * 24 * 3600)
|
|
72
|
+
graph = builder.compile(checkpointer=saver)
|
|
73
|
+
|
|
74
|
+
await graph.ainvoke(state, {"configurable": {"thread_id": "user-42:session-7"}})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 为什么不用官方 `langgraph-checkpoint-redis`?
|
|
80
|
+
|
|
81
|
+
官方 Redis checkpointer 本身写得很好,但它**强制要求 Redis 模块**——官方 README 原文:
|
|
82
|
+
|
|
83
|
+
> **IMPORTANT:** This library requires Redis with the following modules:
|
|
84
|
+
> **RedisJSON**(存取 JSON 数据)和 **RediSearch**(检索与索引)。
|
|
85
|
+
> Redis 8.0+ 默认自带;低版本需要 Redis Stack,或自行安装模块。
|
|
86
|
+
|
|
87
|
+
这就排除了一大类真实环境:
|
|
88
|
+
|
|
89
|
+
* 内网/遗留服务器,Redis 被钉在 5、6、7;
|
|
90
|
+
* 托管 Redis 实例不允许 `MODULE LOAD`;
|
|
91
|
+
* 刻意不带模块的精简自建容器。
|
|
92
|
+
|
|
93
|
+
本包就是为这类环境写的:**只用核心数据结构**(string / hash / zset)。
|
|
94
|
+
|
|
95
|
+
| | `langgraph-checkpoint-plainredis` | `langgraph-checkpoint-redis` |
|
|
96
|
+
|:--|:--|:--|
|
|
97
|
+
| Redis 模块 | **无需任何模块** | 需要 RedisJSON + RediSearch |
|
|
98
|
+
| 最低 Redis | **5.0**(RESP2) | Redis Stack,或 Redis 8.0+ |
|
|
99
|
+
| Python 依赖 | `redis`、`langgraph-checkpoint` | 还要 `redisvl`、`orjson` |
|
|
100
|
+
| 存储方式 | 核心数据结构 | JSON 文档 + 搜索索引 |
|
|
101
|
+
| 同步 API | 有(`RedisSaver`) | 两者都有 |
|
|
102
|
+
| 客户端侧检索 | 注册表 hash + 时间线 zset | RediSearch 索引 |
|
|
103
|
+
|
|
104
|
+
> 如果你的 Redis 是 8.0+ 或 Redis Stack,**请优先用官方包**(功能更全);
|
|
105
|
+
> 本包的存在意义,是让跑不了官方包的环境也能用上持久化。
|
|
106
|
+
|
|
107
|
+
## 安装
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
pip install langgraph-checkpoint-plainredis
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
要求:Python 3.10+、Redis 5.0+、`langgraph-checkpoint>=4.1,<5`。
|
|
114
|
+
|
|
115
|
+
## 快速开始
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import asyncio
|
|
119
|
+
from typing import Annotated, TypedDict
|
|
120
|
+
|
|
121
|
+
from langchain_core.messages import AIMessage, HumanMessage
|
|
122
|
+
from langgraph.graph import START, StateGraph
|
|
123
|
+
from langgraph.graph.message import add_messages
|
|
124
|
+
from langgraph_checkpoint_plainredis import AsyncRedisSaver
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class State(TypedDict):
|
|
128
|
+
messages: Annotated[list, add_messages]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def reply(state: State) -> State:
|
|
132
|
+
return {"messages": [AIMessage(content=f"echo: {state['messages'][-1].content}")]}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def main() -> None:
|
|
136
|
+
# ttl 可选:线程的 key 存活秒数
|
|
137
|
+
saver = AsyncRedisSaver(url="redis://127.0.0.1:6379/0", ttl=7 * 24 * 3600)
|
|
138
|
+
try:
|
|
139
|
+
graph = StateGraph(State).add_node("reply", reply).add_edge(START, "reply").compile(
|
|
140
|
+
checkpointer=saver
|
|
141
|
+
)
|
|
142
|
+
config = {"configurable": {"thread_id": "user-42:session-7"}}
|
|
143
|
+
|
|
144
|
+
await graph.ainvoke({"messages": [HumanMessage("hello")]}, config)
|
|
145
|
+
await graph.ainvoke({"messages": [HumanMessage("again")]}, config)
|
|
146
|
+
|
|
147
|
+
state = await graph.aget_state(config)
|
|
148
|
+
for message in state.values["messages"]:
|
|
149
|
+
print(message.type, "|", message.content)
|
|
150
|
+
|
|
151
|
+
# 时间旅行:回放整条历史
|
|
152
|
+
async for snapshot in graph.aget_state_history(config):
|
|
153
|
+
print(snapshot.config["configurable"]["checkpoint_id"], snapshot.next)
|
|
154
|
+
finally:
|
|
155
|
+
await saver.aclose() # 关闭 Redis 连接
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
asyncio.run(main())
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
复用已有连接池:直接传客户端(其余连接参数会被忽略):
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
import redis.asyncio as aioredis
|
|
165
|
+
from langgraph_checkpoint_plainredis import AsyncRedisSaver
|
|
166
|
+
|
|
167
|
+
client = aioredis.Redis(host="redis.internal", port=6379, db=2, protocol=2)
|
|
168
|
+
saver = AsyncRedisSaver(client=client, prefix="myapp:agent")
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
同步版一样用(`graph.invoke()` / `stream()` / `get_state()`,不写 `await`):
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from langgraph_checkpoint_plainredis import RedisSaver
|
|
175
|
+
|
|
176
|
+
saver = RedisSaver(url="redis://127.0.0.1:6379/0", ttl=7 * 24 * 3600)
|
|
177
|
+
graph = StateGraph(State).add_node("reply", reply).add_edge(START, "reply").compile(
|
|
178
|
+
checkpointer=saver
|
|
179
|
+
)
|
|
180
|
+
config = {"configurable": {"thread_id": "user-42:session-7"}}
|
|
181
|
+
|
|
182
|
+
graph.invoke({"messages": [HumanMessage("hello")]}, config)
|
|
183
|
+
state = graph.get_state(config)
|
|
184
|
+
for snapshot in graph.get_state_history(config): # 同步版历史是普通生成器
|
|
185
|
+
print(snapshot.config["configurable"]["checkpoint_id"], snapshot.next)
|
|
186
|
+
|
|
187
|
+
saver.close() # 收尾关连接
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
> **两个类,按调用风格选一个**:
|
|
191
|
+
> `AsyncRedisSaver`(`redis.asyncio`,配 `ainvoke` / `astream` / `aget_state*`)和
|
|
192
|
+
> `RedisSaver`(阻塞式 `redis`,配 `invoke` / `stream` / `get_state*`)。
|
|
193
|
+
> 两个类共用**完全相同的 key 布局与语义**(同一个 thread 可以由二者混着读写,测试里有专项用例),
|
|
194
|
+
> 用错了会直接抛出指明"改用另一个类"的 `NotImplementedError`。
|
|
195
|
+
>
|
|
196
|
+
> ⚠️ 类名与**官方包**、**官方文档 DIY 示例**同名(都叫 `AsyncRedisSaver`),但 **import 路径不同**:
|
|
197
|
+
> 本包是 `from langgraph_checkpoint_plainredis import AsyncRedisSaver`,官方是 `langgraph.checkpoint.redis`——
|
|
198
|
+
> 两个都装了时注意别引错。
|
|
199
|
+
|
|
200
|
+
## 数据是怎么存的
|
|
201
|
+
|
|
202
|
+
所有 key 都在 `{prefix}:` 下(`prefix` 默认 `lg`):
|
|
203
|
+
|
|
204
|
+
| Key | 类型 | 内容 |
|
|
205
|
+
|:--|:--|:--|
|
|
206
|
+
| `{prefix}:cp:{thread_id}:{ns}:{checkpoint_id}` | string | checkpoint 载荷(父节点 id + metadata) |
|
|
207
|
+
| `{prefix}:blob:{thread_id}:{ns}:{channel}:{version}` | string | 单个 channel 的值 |
|
|
208
|
+
| `{prefix}:wr:{thread_id}:{ns}:{checkpoint_id}` | hash | 该 checkpoint 的 pending writes |
|
|
209
|
+
| `{prefix}:idx:{thread_id}:{ns}` | zset | 时间线(member = checkpoint id,score = 单调计数器) |
|
|
210
|
+
| `{prefix}:threads` | hash | `(thread_id, checkpoint_ns)` 注册表,列表查询用 |
|
|
211
|
+
| `{prefix}:seq` | string | 时间线 score 用的单调计数器 |
|
|
212
|
+
|
|
213
|
+
channel 值按 **(channel, version)** 分开存、**多个 checkpoint 共享**,和 InMemory / SQLite saver 的做法一致:
|
|
214
|
+
往消息列表里追加内容,不会把其它未变更 channel 的整份值重复序列化。时间线用 zset 存 score,
|
|
215
|
+
因此"取最新 checkpoint"和"取某个 checkpoint 之前的"都是 `O(log n)` 的有序集操作,而不是扫描。
|
|
216
|
+
|
|
217
|
+
`{prefix}:threads` 与 `{prefix}:seq` 是**命名空间级**的 key:`adelete_thread()` 会清掉某个线程的
|
|
218
|
+
checkpoint / blob / writes / 时间线 / 注册表项,若要整个应用下线,直接删命名空间:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
# 用 --scan 而不是 KEYS(KEYS 在大库上会阻塞)
|
|
222
|
+
redis-cli --scan --pattern "lg:*" | xargs -r -n 500 redis-cli DEL
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
# 或在 Python 里(本包的连接就能用)
|
|
227
|
+
keys = [key async for key in client.scan_iter(match=f"{prefix}:*", count=500)]
|
|
228
|
+
if keys:
|
|
229
|
+
await client.delete(*keys)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
> 这两个 key **故意不设 TTL**:`seq` 是时间线排序的依据,过期归零会让新旧存档的 score 错乱;
|
|
233
|
+
> `threads` 过期则会让没有新写入的会话从列表里消失(数据还在,但列不出来)。
|
|
234
|
+
|
|
235
|
+
## API
|
|
236
|
+
|
|
237
|
+
两个类实现同一套契约,只是一个 async 一个 sync:
|
|
238
|
+
|
|
239
|
+
| 能力 | `AsyncRedisSaver` | `RedisSaver` | 说明 |
|
|
240
|
+
|:--|:--|:--|:--|
|
|
241
|
+
| 读单个存档 | `aget_tuple(config)` | `get_tuple(config)` | 不指定 `checkpoint_id` 就是最新 |
|
|
242
|
+
| 列存档 | `alist(...)` | `list(...)` | `config=None` 时遍历全部 thread/namespace |
|
|
243
|
+
| 写存档 | `aput(...)` | `put(...)` | 写 checkpoint + 对应 channel blob |
|
|
244
|
+
| 写中间写入 | `aput_writes(...)` | `put_writes(...)` | 按 `(task_id, channel)` 幂等 |
|
|
245
|
+
| 删线程 | `adelete_thread(id)` | `delete_thread(id)` | 精确删除 checkpoint/blob/writes/时间线/注册表项 |
|
|
246
|
+
| 关连接 | `aclose()` | `close()` | 进程退出时调用 |
|
|
247
|
+
|
|
248
|
+
未实现的那一侧(如 `RedisSaver.aget_tuple`)会抛 `NotImplementedError`,并把该用哪个类写在错误信息里。
|
|
249
|
+
|
|
250
|
+
| 方法 | 说明 |
|
|
251
|
+
|:--|:--|
|
|
252
|
+
| `aget_tuple(config)` | 取最新 checkpoint,或 `checkpoint_id` 指定的那一条 |
|
|
253
|
+
| `alist(config, *, filter, before, limit)` | 新→旧列出;`config=None` 时遍历全部 thread/namespace |
|
|
254
|
+
| `aput(config, checkpoint, metadata, new_versions)` | 写入 checkpoint 与对应 channel blob |
|
|
255
|
+
| `aput_writes(config, writes, task_id, task_path)` | 按 `(task_id, channel)` 幂等,与 `InMemorySaver` 一致 |
|
|
256
|
+
| `adelete_thread(thread_id)` | 删除该线程的 checkpoint、blob、writes、时间线与注册表项 |
|
|
257
|
+
| `aclose()` | 关闭 Redis 连接 |
|
|
258
|
+
|
|
259
|
+
同步方法(`get_tuple` / `put` / `put_writes` / `list`)抛出 `NotImplementedError` 并给出替代方案——
|
|
260
|
+
请用 `ainvoke` / `astream`(或 `aget_state*`)。
|
|
261
|
+
|
|
262
|
+
## 语义与保证
|
|
263
|
+
|
|
264
|
+
* **顺序** —— `alist` 按**写入时间**倒序(时间线 score)。LangGraph 默认 checkpoint id 是单调的 UUID6,
|
|
265
|
+
因此这与参考实现的"按 checkpoint id 倒序"在真实图里等价。
|
|
266
|
+
* **`before`** —— 排他(只返回更早的),通过时间线 score 定位;即使 id 不能按字典序比较也正确。
|
|
267
|
+
* **`filter`** —— 对 checkpoint metadata 过滤,与参考实现同规则。
|
|
268
|
+
* **写入幂等** —— 常规 channel 每个 `(task_id, channel)` 只写一次;特殊 channel
|
|
269
|
+
(`__interrupt__` / `__error__` / `__scheduled__` / `__resume__`)覆盖,与 `InMemorySaver` 相同。
|
|
270
|
+
* **被清空的 channel** —— 出现在 `new_versions` 但不在 `channel_values` 里的 channel,
|
|
271
|
+
会写成 "empty" blob,回读时从 `channel_values` 中省略。
|
|
272
|
+
* **`ttl`** —— 每次写入都会刷新 checkpoint / blob / 时间线 / writes 四类 key 的过期时间,
|
|
273
|
+
活跃线程不断续期,被抛弃的线程自然消失。
|
|
274
|
+
* **删除是精确的** —— thread id 常含 `:`(例如 `f"{user_id}:{session_id}"`),甚至可能含 glob 元字符;
|
|
275
|
+
删除走注册表 + 精确 key,**不用裸模式匹配**。删除线程 `a` 不会碰到线程 `a:b`。
|
|
276
|
+
* **注册表自愈** —— 时间线已过期(TTL)的条目会在列表查询时被惰性清理;这一步搭在“本来就要读时间线”的
|
|
277
|
+
那次查询上,因此 `alist(None)` **不会**为每个条目产生额外的 `EXISTS` 往返。
|
|
278
|
+
* **兜底删除的前缀风险** —— `adelete_thread()` 正常走注册表精确删除;仅当注册表里没有该线程的条目时
|
|
279
|
+
才回退到模式扫描。回退时会再用注册表核对命中的时间线归属(能对上就跳过别的线程),
|
|
280
|
+
但**若注册表整体缺失**,`a` 与 `a:b` 这类前缀重叠仍可能误删 —— 别手动删 `{prefix}:threads` 里的条目。
|
|
281
|
+
|
|
282
|
+
## 已知限制与 Roadmap
|
|
283
|
+
|
|
284
|
+
* **两个类分开(同步 / 异步)**,各自只实现一侧接口;用错了错误信息会指向另一个类。
|
|
285
|
+
* 列表查询靠注册表 hash,而不是服务端索引:`alist(None)` 的代价随**线程数量**线性增长(而非随数据量)。
|
|
286
|
+
按单个线程查询是 `O(log n)`。
|
|
287
|
+
* 回读一个 checkpoint 用一次 `MGET` 取全部 blob(不是每个 channel 一次往返)。
|
|
288
|
+
* 载荷外面套了一层 JSON+base64,方便用 `redis-cli` 直接查看;直接存原始字节能再省约 25%,有需要再加。
|
|
289
|
+
* 未实现:同步 API、类似 `search` 的跨线程查询。
|
|
290
|
+
* `adelete_thread()` 每个存档一次 `GET`(为了算出它用了哪些 blob);代价受**单线程存档数**约束
|
|
291
|
+
(不是全库线程数),且删档是低频操作,暂不优化。
|
|
292
|
+
|
|
293
|
+
## 兼容性
|
|
294
|
+
|
|
295
|
+
* **Redis**:5.0 → 8.x,有无模块均可。默认 RESP2(`protocol=2`);服务端 6+ 想用 RESP3 可传 `protocol=3`。
|
|
296
|
+
CI 会针对 `redis:5`、`redis:7`、`redis:8` 三种容器跑完整测试。
|
|
297
|
+
* **Python**:3.10 / 3.11 / 3.12 / 3.13。
|
|
298
|
+
* **LangGraph**:`langgraph-checkpoint >= 4.1, < 5`。上游 checkpoint 接口仍在演进,锁大版本让升级可控。
|
|
299
|
+
|
|
300
|
+
## 安全
|
|
301
|
+
|
|
302
|
+
checkpoint 使用 LangGraph 的序列化器(默认 `JsonPlusSerializer`),对特殊载荷可能回落到 pickle 编码。
|
|
303
|
+
请把 Redis 当作**可信存储**:能写入该 key 空间的人,就可能构造出被你的进程反序列化的载荷。
|
|
304
|
+
如果这不接受,可通过 `serde=` 传入更严格的序列化器,并为每个应用分配独立的 `db` / `prefix`。
|
|
305
|
+
|
|
306
|
+
## 开发与测试
|
|
307
|
+
|
|
308
|
+
测试需要**真实 Redis**(这正是本包的意义所在):
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
# 1) 准备一个完全没有模块的 Redis(redis:5 即可)
|
|
312
|
+
docker run --rm -d -p 6379:6379 --name plainredis-test redis:5
|
|
313
|
+
|
|
314
|
+
# 2) 建环境并安装(conda 或 venv 都行)
|
|
315
|
+
conda create -y -n PlainRedis python=3.11 && conda activate PlainRedis
|
|
316
|
+
pip install -e ".[dev]" # dev 里含 pytest / pytest-asyncio / langgraph
|
|
317
|
+
|
|
318
|
+
# 3) 跑测试(二选一)
|
|
319
|
+
pytest -v --redis-url=redis://127.0.0.1:6379/15
|
|
320
|
+
PLAINREDIS_TEST_URL=redis://127.0.0.1:6379/15 pytest -v
|
|
321
|
+
|
|
322
|
+
# 4) 跑示例
|
|
323
|
+
python examples/basic.py # 异步版(AsyncRedisSaver + ainvoke)
|
|
324
|
+
python examples/basic_sync.py # 同步版(RedisSaver + invoke),同一个 key 布局
|
|
325
|
+
# 两者都可用 PLAINREDIS_URL 覆盖默认 redis://127.0.0.1:6379/0
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
测试默认使用 **db 15** + 每个用例随机 key 前缀,跑完自行清理,因此可以安全地指向共享的开发实例。
|
|
329
|
+
其中 `tests/test_parity_memory.py` 会把同一串操作分别打到本包与官方 `InMemorySaver` 上并逐字段比对,
|
|
330
|
+
任何语义漂移都会立刻失败。
|
|
331
|
+
|
|
332
|
+
## License
|
|
333
|
+
|
|
334
|
+
MIT —— 见 [LICENSE](LICENSE)。
|