Skip to content

Commit 034d506

Browse files
committed
feat(scheduler): 会话粘性——对话进行中不切换凭证
OpenAI 协议无会话概念,但同对话下一轮的 messages 必然以本轮完整 messages 为前缀。据此用增量前缀指纹(掺入用户名防跨用户串号)做 粘性匹配:命中则固定复用原凭证,不再按到期积分/健康度重排,避免 对话中途换号触发上游风控、丢掉上游提示词缓存。 - 粘住的凭证出错仍走正常轮换(≤3 次),成功后重新粘到实际服务的凭证 - 凭证被删/冷却/禁用/属其他上游时自动回退常规调度,不会永久失粘 - CONVERSATION_STICKY_SECONDS(默认 3600s)控制 TTL,≤0 关闭 - 纯内存实现,重启丢粘性只影响一轮选号
1 parent 56f700e commit 034d506

9 files changed

Lines changed: 432 additions & 7 deletions

File tree

‎README.en.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ upstream channels, with a shared credential pool, unified scheduling, and per-us
1313
- **OpenAI-compatible surface**: `/v1/chat/completions` (streaming and non-streaming), `/v1/models`, `/v1/user/balance` (DeepSeek-compatible balance query)
1414
- **Two upstreams, one model namespace**: flat model names auto-route — credits expiring within the window first, then health; `model@provider` pins an upstream
1515
- **Expiry-aware scheduling**: sums the credits expiring within `QUOTA_EXPIRY_WINDOW_SECONDS` (default 36h) per credential and burns the largest first, so near-expiry quota is not wasted
16+
- **Conversation stickiness**: multi-turn conversations keep the same credential (matched by message-prefix fingerprint) and only rotate on errors, avoiding upstream risk control and lost prompt cache
1617
- **Three-state health + tiered cooldowns**: quota exhausted 12h, rate limited 60s, consecutive errors 10m, dead session disabled
1718
- **Shared credential pool**: admins maintain credentials, everyone shares them; usage is tracked per user
1819
- **Encrypted credentials at rest**: Fernet (AES-128-CBC + HMAC), key from `APP_SECRET`

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
## 特性
1010

1111
- **OpenAI 兼容出口**:`/v1/chat/completions`(流式 + 非流式)、`/v1/models`、`/v1/user/balance`(DeepSeek 兼容余额查询)
12-
- **统一调度**:扁平模型名按健康度自动选号,`模型@渠道` 强制指定;三态健康度 + 分级冷却自动避开坏号;积分 36h 内即将到期多者优先(先用掉,避免过期浪费;管理台凭证列表直接显示每个账号的到期积分)
12+
- **统一调度**:扁平模型名按健康度自动选号,`模型@渠道` 强制指定;三态健康度 + 分级冷却自动避开坏号;积分 36h 内即将到期多者优先(先用掉,避免过期浪费;管理台凭证列表直接显示每个账号的到期积分);同一对话多轮请求粘住同一凭证(对话进行中不换号,出错才轮换)
1313
- **公共凭证池**:admin 集中维护、全员共享;按人统计用量
1414
- **完整凭证运维**:设备码登录、多账号切换、额度探测、每日签到、token 预刷新;凭证加密入库(`APP_SECRET`)
1515
- **脱敏统计**:不存对话内容;明细 90 天、小时汇总永久;按人/渠道/模型可视化
@@ -103,6 +103,7 @@ curl http://127.0.0.1:8000/v1/user/balance -H "Authorization: Bearer sk-你的ke
103103
| `DATA_DIR` | `./data` | SQLite 与运行数据目录 |
104104
| `QUOTA_PROBE_MINUTES` | `60` | 额度探测周期 |
105105
| `QUOTA_EXPIRY_WINDOW_SECONDS` | `129600` | 到期排序窗口:把距到期 ≤ 该秒数的积分加总,多的账号先用(避免积分过期浪费);`≤0` 关闭,退回纯健康度排序 |
106+
| `CONVERSATION_STICKY_SECONDS` | `3600` | 会话粘性 TTL:同一对话(消息前缀延续)多轮请求固定用同一凭证;凭证出错仍会轮换,成功后重新粘定;`≤0` 关闭 |
106107
| `MODEL_BLOCKLIST` | `custom_model_*,*sub*agent*,summary,browser_use_*` | 模型列表黑名单(仅影响列表展示) |
107108
| `ALLOWED_HOSTS` | 空 | Host 白名单,防 DNS rebinding |
108109

‎docker-compose.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ services:
1818
QUOTA_PROBE_MINUTES: ${QUOTA_PROBE_MINUTES:-60}
1919
# 到期排序窗口(秒):把距到期 ≤ 该值的积分加总,多的账号先用;≤0 关闭
2020
QUOTA_EXPIRY_WINDOW_SECONDS: ${QUOTA_EXPIRY_WINDOW_SECONDS:-129600}
21+
# 会话粘性 TTL(秒):同一对话多轮请求固定用同一凭证;≤0 关闭
22+
CONVERSATION_STICKY_SECONDS: ${CONVERSATION_STICKY_SECONDS:-3600}
2123
LOG_LEVEL: ${LOG_LEVEL:-INFO}
2224
ports:
2325
- "${PORT:-8000}:8000"

‎src/config.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ class Settings(BaseSettings):
3939
# 到期排序窗口:把「距到期 ≤ 该秒数」的积分加总,作为选号第一排序指标(多者先用);
4040
# CodeBuddy 是每日 100 积分 × N 的小包;≤0 关闭该指标,退回纯健康度排序
4141
quota_expiry_window_seconds: int = 36 * 3600
42+
# 会话粘性 TTL(秒):同一对话(消息前缀延续)的多轮请求固定用同一凭证,
43+
# 对话进行中不换号(避免上游风控与丢失上游提示词缓存);凭证出错仍会
44+
# 正常轮换,成功后重新粘定。≤0 关闭粘性
45+
conversation_sticky_seconds: int = 3600
4246

4347
# 后台任务
4448
quota_probe_minutes: int = 60

‎src/engine/affinity.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""会话粘性:同一对话的多轮请求固定使用同一凭证。
2+
3+
OpenAI Chat Completions 本身无会话概念,但客户端(agent/CLI)的「对话」
4+
表现为消息数组的前缀延续:下一轮的 messages 以上一轮的完整 messages
5+
为前缀再追加。据此以增量前缀指纹做匹配,命中则请求固定复用原凭证,
6+
不再按到期积分/健康度重排——对话进行中换号会触发上游风控并丢掉
7+
上游侧的提示词缓存。
8+
9+
指纹链里掺入用户名,防止不同 API 用户的相同消息数组串到同一凭证。
10+
TTL 内没有后续轮次即视为对话结束,条目过期;条目数有上限,超量先淘汰
11+
过期条目、再按最旧淘汰。纯内存即可:重启后丢粘性只影响一轮选号。
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import hashlib
17+
import json
18+
import time
19+
20+
21+
def _message_digest(message: object) -> bytes:
22+
"""单条消息的规范哈希:键序归一,杜绝 JSON 键序差异造成假失配。"""
23+
text = json.dumps(message, ensure_ascii=False, sort_keys=True,
24+
separators=(",", ":"), default=str)
25+
return hashlib.sha256(text.encode("utf-8")).digest()
26+
27+
28+
class ConversationAffinity:
29+
"""前缀指纹 → 凭证的粘性表(单事件循环内使用,无需加锁)。"""
30+
31+
def __init__(self, *, ttl_seconds: int, max_entries: int = 512) -> None:
32+
self.ttl_seconds = ttl_seconds
33+
self.max_entries = max_entries
34+
# fingerprint_hex → (credential_id, expires_at);dict 保持插入序,供淘汰
35+
self._entries: dict[str, tuple[str, float]] = {}
36+
37+
def pin_for(self, messages: list[dict], username: str, *,
38+
now: float | None = None) -> str | None:
39+
"""最长前缀匹配的粘性凭证;无匹配(含禁用/过期)返回 None。
40+
41+
逐条累积指纹并在每一步查表:上一轮请求存的指纹正是本轮某个前缀
42+
的指纹,最长匹配者即本对话最近一轮实际使用的凭证。
43+
"""
44+
if self.ttl_seconds <= 0 or not messages:
45+
return None
46+
current = now if now is not None else time.monotonic()
47+
pinned: str | None = None
48+
chain = hashlib.sha256(username.encode("utf-8")).digest()
49+
for message in messages:
50+
chain = hashlib.sha256(chain + _message_digest(message)).digest()
51+
entry = self._entries.get(chain.hex())
52+
if entry is None:
53+
continue
54+
credential_id, expires_at = entry
55+
if expires_at <= current:
56+
del self._entries[chain.hex()]
57+
continue
58+
pinned = credential_id
59+
self._touch(chain.hex(), credential_id, current)
60+
return pinned
61+
62+
def remember(self, messages: list[dict], username: str, credential_id: str, *,
63+
now: float | None = None) -> None:
64+
"""记录本轮请求的完整指纹 → 实际服务的凭证(成功后调用)。"""
65+
if self.ttl_seconds <= 0 or not messages:
66+
return
67+
current = now if now is not None else time.monotonic()
68+
chain = hashlib.sha256(username.encode("utf-8")).digest()
69+
for message in messages:
70+
chain = hashlib.sha256(chain + _message_digest(message)).digest()
71+
self._touch(chain.hex(), credential_id, current)
72+
self._evict(current)
73+
74+
def _touch(self, key: str, credential_id: str, now: float) -> None:
75+
"""写入/刷新条目并移到末尾(最近使用优先保留)。"""
76+
self._entries.pop(key, None)
77+
self._entries[key] = (credential_id, now + self.ttl_seconds)
78+
79+
def _evict(self, now: float) -> None:
80+
"""超上限时先清过期条目,仍超则按最旧淘汰。"""
81+
if len(self._entries) <= self.max_entries:
82+
return
83+
for key in [k for k, (_, exp) in self._entries.items() if exp <= now]:
84+
del self._entries[key]
85+
while len(self._entries) > self.max_entries:
86+
self._entries.pop(next(iter(self._entries)))
87+
88+
def __len__(self) -> int:
89+
return len(self._entries)

‎src/engine/executor.py‎

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ class ExecutorDeps:
4646
# provider → {小写模型名: 上游原始 id};api/models.list_models 拉取后就地更新。
4747
# 用于把独有模型的候选上游收窄到真正登记了它的上游,避免白打一次请求
4848
model_aliases: dict[str, dict[str, str]] | None = None
49+
# 会话粘性(ConversationAffinity);None 表示关闭。对话进行中固定用原
50+
# 凭证,出错才轮换,成功后重新粘定实际服务的凭证
51+
affinity: Any | None = None
4952

5053
def record(self, **fields: Any) -> None:
5154
"""统计写入失败绝不能影响聊天响应。"""
@@ -136,6 +139,8 @@ async def stream(self, request: ChatRequest, *, username: str = "unknown"
136139
target = self.resolve_target(request)
137140
state = _StreamState(translator=StreamTranslator(target.model),
138141
started=time.monotonic(), username=username)
142+
if self._deps.affinity is not None:
143+
state.affinity_id = self._deps.affinity.pin_for(request.messages, username)
139144
try:
140145
async for frame in self._stream_loop(request, target, state):
141146
yield frame
@@ -159,7 +164,7 @@ async def _stream_loop(self, request: ChatRequest, target: ModelTarget,
159164
last_kind: ErrKind | None = None
160165

161166
while True:
162-
pick = self._pick(target, tried)
167+
pick = self._pick(target, tried, state.affinity_id)
163168
if pick is None:
164169
if last_kind is ErrKind.INVALID:
165170
# 所有候选上游都拒绝了该模型:400 语义而非 503
@@ -212,6 +217,7 @@ async def _stream_loop(self, request: ChatRequest, target: ModelTarget,
212217
yield frame
213218
else:
214219
self._deps.credentials.save_success(credential_id)
220+
self._remember(request, state.username, credential_id)
215221
self._record_success(target, state, provider_id, credential_id)
216222
for frame in state.translator.finish():
217223
yield frame
@@ -291,9 +297,12 @@ async def complete(self, request: ChatRequest, *, username: str = "unknown"
291297
last_credential: str | None = None
292298
# 首事件时刻:上游响应的第一个事件(TTFE,非流式的首字延迟);跨重试只记最早一次
293299
first_event_at: float | None = None
300+
# 会话粘性:对话上一轮用过哪个凭证,本轮优先复用
301+
affinity_id = (self._deps.affinity.pin_for(request.messages, username)
302+
if self._deps.affinity is not None else None)
294303

295304
while True:
296-
pick = self._pick(target, tried)
305+
pick = self._pick(target, tried, affinity_id)
297306
if pick is None:
298307
if last_kind is ErrKind.INVALID:
299308
# 所有候选上游都拒绝了该模型:400 而非 503
@@ -356,6 +365,7 @@ async def complete(self, request: ChatRequest, *, username: str = "unknown"
356365
last_error = error
357366
else:
358367
self._deps.credentials.save_success(credential_id)
368+
self._remember(request, username, credential_id)
359369
usage = result.get("usage") or {}
360370
self._deps.record(
361371
username=username, provider=provider_id,
@@ -386,7 +396,13 @@ async def complete(self, request: ChatRequest, *, username: str = "unknown"
386396

387397
# -------------------------------------------------------------- 内部
388398

389-
def _pick(self, target: ModelTarget, tried: set[str]):
399+
def _remember(self, request: ChatRequest, username: str, credential_id: str) -> None:
400+
"""成功后把本对话粘到实际服务的凭证(轮换降级后随之换粘)。"""
401+
if self._deps.affinity is not None:
402+
self._deps.affinity.remember(request.messages, username, credential_id)
403+
404+
def _pick(self, target: ModelTarget, tried: set[str],
405+
affinity_id: str | None = None):
390406
# 区分两种情况:模型所属 provider 完全没注册(400)vs 注册了但没有可用凭证(503)
391407
registered = [pid for pid in self._narrow_providers(target)
392408
if pid in self._deps.providers]
@@ -395,15 +411,31 @@ def _pick(self, target: ModelTarget, tried: set[str]):
395411
candidates = self._deps.credentials.candidates(registered)
396412
if not candidates:
397413
return None
398-
credential_id = self._deps.scheduler.select(candidates, tried, int(time.time()))
414+
credential_id = (self._sticky(candidates, tried, affinity_id)
415+
or self._deps.scheduler.select(candidates, tried, int(time.time())))
399416
if credential_id is None:
400417
return None
401418
credential_data = self._deps.credentials.credential_data(credential_id)
402419
if credential_data is None: # 并发删除
403420
tried.add(credential_id)
404-
return self._pick(target, tried)
421+
return self._pick(target, tried, affinity_id)
405422
return credential_id, credential_data
406423

424+
def _sticky(self, candidates: list, tried: set[str],
425+
affinity_id: str | None) -> str | None:
426+
"""会话粘性优先:指纹命中的凭证仍可选时直接复用,不参与排序。
427+
428+
只校验「在候选池里且未冷却/禁用/本请求已轮换过」;其余情况
429+
(凭证被删、冷却中、用户强制了别的上游)回退常规调度,成功后
430+
重新粘定,对话不会因此永久失粘。
431+
"""
432+
if affinity_id is None or affinity_id in tried:
433+
return None
434+
match = next((c for c in candidates if c.credential_id == affinity_id), None)
435+
if match is None or not match.is_selectable(int(time.time())):
436+
return None
437+
return affinity_id
438+
407439
def _narrow_providers(self, target: ModelTarget) -> tuple[str, ...]:
408440
"""模型目录能证明归属时,把候选上游收窄到登记了该模型的上游。
409441
@@ -443,6 +475,7 @@ class _StreamState:
443475
username: str
444476
provider: str = "-"
445477
credential_id: str | None = None
478+
affinity_id: str | None = None
446479
_first_byte_at: float | None = None
447480
# 已记账标记:断开分支防与正常成功路径重复记账
448481
recorded: bool = False

‎src/main.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from .db.crypto import CredentialCipher, CredentialDecryptError
3535
from .db.migrate import apply_schema
3636
from .db.repo import ApiKeyRepository, CredentialRepository
37+
from .engine.affinity import ConversationAffinity
3738
from .engine.executor import Executor, ExecutorDeps, NoHealthyCredential, NoProviderForModel
3839
from .engine.model_resolver import UnknownModelError
3940
from .engine.scheduler import Scheduler
@@ -207,6 +208,8 @@ def build_app(settings: Settings | None = None, *, providers: dict | None = None
207208
expiry_window=config.quota_expiry_window_seconds),
208209
default_model=config.default_model,
209210
stats=StatsCollector(db),
211+
affinity=ConversationAffinity(
212+
ttl_seconds=config.conversation_sticky_seconds),
210213
upstream_model_name=lambda provider_id, model_name: (
211214
model_aliases.get(provider_id, {}).get(
212215
model_name.lower(), model_name)

0 commit comments

Comments
 (0)