sycommon-python-lib 0.2.6a2__py3-none-any.whl → 0.2.6a4__py3-none-any.whl
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.
- sycommon/agent/deep_agent.py +13 -1
- sycommon/agent/sandbox/consistent_hash.py +175 -0
- sycommon/agent/sandbox/http_sandbox_backend.py +3 -6
- sycommon/agent/summarization_utils.py +62 -0
- sycommon/llm/token_utils.py +8 -1
- sycommon/tests/test_consistent_hash.py +279 -0
- {sycommon_python_lib-0.2.6a2.dist-info → sycommon_python_lib-0.2.6a4.dist-info}/METADATA +1 -1
- {sycommon_python_lib-0.2.6a2.dist-info → sycommon_python_lib-0.2.6a4.dist-info}/RECORD +11 -9
- {sycommon_python_lib-0.2.6a2.dist-info → sycommon_python_lib-0.2.6a4.dist-info}/WHEEL +0 -0
- {sycommon_python_lib-0.2.6a2.dist-info → sycommon_python_lib-0.2.6a4.dist-info}/entry_points.txt +0 -0
- {sycommon_python_lib-0.2.6a2.dist-info → sycommon_python_lib-0.2.6a4.dist-info}/top_level.txt +0 -0
sycommon/agent/deep_agent.py
CHANGED
|
@@ -982,7 +982,7 @@ class DeepAgent:
|
|
|
982
982
|
不在此处重试整个 graph。
|
|
983
983
|
"""
|
|
984
984
|
import httpx
|
|
985
|
-
from openai import APIConnectionError, APIError, APITimeoutError
|
|
985
|
+
from openai import APIConnectionError, APIError, APITimeoutError, BadRequestError
|
|
986
986
|
|
|
987
987
|
last_error = None
|
|
988
988
|
sandbox_retries = 0
|
|
@@ -1010,6 +1010,18 @@ class DeepAgent:
|
|
|
1010
1010
|
print(
|
|
1011
1011
|
f"[DeepAgent] _astream_with_retry: stream ended normally, total_chunks={chunk_count}", flush=True)
|
|
1012
1012
|
return
|
|
1013
|
+
except BadRequestError as e:
|
|
1014
|
+
# BadRequestError(含 ContextOverflowError)不重试:
|
|
1015
|
+
# 上下文溢出由 SummarizationMiddleware 在模型调用层处理,
|
|
1016
|
+
# 重试只会浪费时间且无法恢复。
|
|
1017
|
+
from langchain_core.exceptions import ContextOverflowError as _COE
|
|
1018
|
+
if isinstance(e, _COE):
|
|
1019
|
+
SYLogger.warning(
|
|
1020
|
+
f"[DeepAgent] ContextOverflowError: {e}, 不重试,交由 SummarizationMiddleware 处理")
|
|
1021
|
+
else:
|
|
1022
|
+
SYLogger.warning(
|
|
1023
|
+
f"[DeepAgent] BadRequestError: {e}, 不重试")
|
|
1024
|
+
raise
|
|
1013
1025
|
except (APIConnectionError, APIError, APITimeoutError, ConnectionError, httpx.RemoteProtocolError) as e:
|
|
1014
1026
|
last_error = e
|
|
1015
1027
|
print(
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
一致性哈希沙箱路由
|
|
3
|
+
|
|
4
|
+
虚拟槽位层:将 0-10000 的虚拟槽位通过一致性哈希环映射到真实沙箱实例。
|
|
5
|
+
用户到虚拟槽位的映射永久固定(只依赖 user_id),增减真实沙箱只影响槽位到实例的映射。
|
|
6
|
+
|
|
7
|
+
架构:
|
|
8
|
+
┌────────────────────────────────────────────────────────────┐
|
|
9
|
+
│ 用户请求 │
|
|
10
|
+
│ │
|
|
11
|
+
│ user_id ──MD5──▶ 虚拟槽位号 (0-10000) │
|
|
12
|
+
│ │ │
|
|
13
|
+
│ ▼ │
|
|
14
|
+
│ ┌─────────────────────────────────────────────────────┐ │
|
|
15
|
+
│ │ 一致性哈希环 │ │
|
|
16
|
+
│ │ │ │
|
|
17
|
+
│ │ 每个真实实例在环上占 150 个虚拟节点 │ │
|
|
18
|
+
│ │ 槽位号顺时针找到最近的虚拟节点 → 真实实例 │ │
|
|
19
|
+
│ └─────────────────────────────────────────────────────┘ │
|
|
20
|
+
│ │ │
|
|
21
|
+
│ ▼ │
|
|
22
|
+
│ ┌─────────────────────────────────────────────────────┐ │
|
|
23
|
+
│ │ 真实沙箱实例 │ │
|
|
24
|
+
│ │ 192.168.1.100:8080 (实例1) │ │
|
|
25
|
+
│ │ 192.168.1.101:8080 (实例2) │ │
|
|
26
|
+
│ │ 192.168.1.102:8080 (实例3) │ │
|
|
27
|
+
│ └─────────────────────────────────────────────────────┘ │
|
|
28
|
+
│ │
|
|
29
|
+
│ 增减实例时:仅相邻槽位重新分配,绝大部分用户不受影响 │
|
|
30
|
+
└────────────────────────────────────────────────────────────┘
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
from bisect import bisect_right
|
|
35
|
+
from typing import Optional
|
|
36
|
+
|
|
37
|
+
from sycommon.logging.kafka_log import SYLogger
|
|
38
|
+
|
|
39
|
+
# 虚拟槽位数量:0 到 VIRTUAL_SLOT_COUNT-1
|
|
40
|
+
VIRTUAL_SLOT_COUNT = 10001 # 0-10000
|
|
41
|
+
|
|
42
|
+
# 每个真实实例在哈希环上的虚拟节点数(越多分布越均匀)
|
|
43
|
+
DEFAULT_VIRTUAL_NODES_PER_INSTANCE = 500
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ConsistentHashMap:
|
|
47
|
+
"""一致性哈希环,将虚拟槽位号映射到真实沙箱实例。
|
|
48
|
+
|
|
49
|
+
使用虚拟节点技术保证负载均衡均匀。
|
|
50
|
+
线程安全:build() 后 ring 为不可变结构,get_instance() 无需加锁。
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, virtual_nodes_per_instance: int = DEFAULT_VIRTUAL_NODES_PER_INSTANCE):
|
|
54
|
+
self._ring: list[int] = [] # 排序的哈希环(虚拟节点的哈希值)
|
|
55
|
+
self._node_map: dict[int, str] = {} # ring_hash -> instance_key (ip:port)
|
|
56
|
+
self._virtual_nodes = virtual_nodes_per_instance
|
|
57
|
+
|
|
58
|
+
def build(self, instances: list[dict]) -> None:
|
|
59
|
+
"""从 Nacos 实例列表构建一致性哈希环。
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
instances: Nacos 实例列表,每个元素包含 'ip' 和 'port' 字段
|
|
63
|
+
"""
|
|
64
|
+
self._ring = []
|
|
65
|
+
self._node_map = {}
|
|
66
|
+
|
|
67
|
+
for ins in instances:
|
|
68
|
+
key = f"{ins['ip']}:{ins['port']}"
|
|
69
|
+
for i in range(self._virtual_nodes):
|
|
70
|
+
# 虚拟节点标识
|
|
71
|
+
vnode_key = f"{key}#vn{i}"
|
|
72
|
+
h = self._hash(vnode_key)
|
|
73
|
+
self._ring.append(h)
|
|
74
|
+
self._node_map[h] = key
|
|
75
|
+
|
|
76
|
+
self._ring.sort()
|
|
77
|
+
SYLogger.info(
|
|
78
|
+
f"[ConsistentHash] 哈希环构建完成: "
|
|
79
|
+
f"{len(instances)} 个实例, {len(self._ring)} 个虚拟节点"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def get_instance(self, slot: int) -> Optional[str]:
|
|
83
|
+
"""虚拟槽位号 → 最近的真实实例 key (ip:port)。
|
|
84
|
+
|
|
85
|
+
在哈希环上顺时针查找最近的虚拟节点。
|
|
86
|
+
如果 slot 对应的位置之后没有节点,回到环首。
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
slot: 虚拟槽位号 (0 ~ VIRTUAL_SLOT_COUNT-1)
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
实例 key "ip:port",环为空时返回 None
|
|
93
|
+
"""
|
|
94
|
+
if not self._ring:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
# 将 slot 映射到哈希环上的一个位置
|
|
98
|
+
h = self._hash(str(slot))
|
|
99
|
+
# 顺时针找到第一个 >= h 的节点
|
|
100
|
+
idx = bisect_right(self._ring, h)
|
|
101
|
+
if idx >= len(self._ring):
|
|
102
|
+
idx = 0 # 回到环首
|
|
103
|
+
return self._node_map[self._ring[idx]]
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _hash(key: str) -> int:
|
|
107
|
+
"""将字符串映射到 0 ~ 2^64-1 的哈希空间"""
|
|
108
|
+
return int(hashlib.md5(key.encode()).hexdigest(), 16) % (2 ** 64)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def user_to_slot(user_id: str) -> int:
|
|
112
|
+
"""用户 ID → 固定虚拟槽位号。
|
|
113
|
+
|
|
114
|
+
同一 user_id 永远返回相同的槽位号。
|
|
115
|
+
槽位号范围: 0 ~ VIRTUAL_SLOT_COUNT-1
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
user_id: 用户 ID
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
虚拟槽位号 (int)
|
|
122
|
+
"""
|
|
123
|
+
hash_value = int(hashlib.md5(user_id.encode()).hexdigest(), 16)
|
|
124
|
+
return hash_value % VIRTUAL_SLOT_COUNT
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def resolve_user_instance(user_id: str, instances: list[dict]) -> dict:
|
|
128
|
+
"""用户 ID + 可用实例列表 → 目标真实实例。
|
|
129
|
+
|
|
130
|
+
完整路由流程:
|
|
131
|
+
1. user_id → 虚拟槽位号(永久固定)
|
|
132
|
+
2. 构建一致性哈希环
|
|
133
|
+
3. 槽位号 → 真实实例
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
user_id: 用户 ID
|
|
137
|
+
instances: Nacos 可用实例列表,每个元素包含 'ip' 和 'port'
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
目标实例 dict(含 'ip', 'port' 等字段)
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
RuntimeError: 实例列表为空
|
|
144
|
+
"""
|
|
145
|
+
if not instances:
|
|
146
|
+
raise RuntimeError("实例列表为空,无法路由")
|
|
147
|
+
|
|
148
|
+
# 单实例直接返回,无需哈希
|
|
149
|
+
if len(instances) == 1:
|
|
150
|
+
return instances[0]
|
|
151
|
+
|
|
152
|
+
slot = user_to_slot(user_id)
|
|
153
|
+
ring = ConsistentHashMap()
|
|
154
|
+
ring.build(instances)
|
|
155
|
+
target_key = ring.get_instance(slot)
|
|
156
|
+
|
|
157
|
+
if target_key is None:
|
|
158
|
+
# 兜底:环构建失败时取第一个实例
|
|
159
|
+
SYLogger.warning(
|
|
160
|
+
f"[ConsistentHash] 哈希环为空,回退到第一个实例")
|
|
161
|
+
return instances[0]
|
|
162
|
+
|
|
163
|
+
# 查找目标实例
|
|
164
|
+
for ins in instances:
|
|
165
|
+
if f"{ins['ip']}:{ins['port']}" == target_key:
|
|
166
|
+
SYLogger.info(
|
|
167
|
+
f"[ConsistentHash] 路由: user_id={user_id} -> "
|
|
168
|
+
f"slot={slot} -> instance={target_key}"
|
|
169
|
+
)
|
|
170
|
+
return ins
|
|
171
|
+
|
|
172
|
+
# 未匹配(理论上不会发生),兜底
|
|
173
|
+
SYLogger.warning(
|
|
174
|
+
f"[ConsistentHash] 未找到匹配实例 key={target_key},回退到第一个实例")
|
|
175
|
+
return instances[0]
|
|
@@ -316,13 +316,10 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
|
|
|
316
316
|
if not instances:
|
|
317
317
|
raise RuntimeError(f"服务 [{service_name}] 无可用实例(已过滤非所有者本地沙箱)")
|
|
318
318
|
|
|
319
|
-
# 基于用户ID
|
|
319
|
+
# 基于用户ID的一致性哈希负载均衡:用户固定映射到虚拟槽位,槽位映射到真实实例
|
|
320
320
|
if load_balance and len(instances) > 1:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
instance = instances[instance_index]
|
|
324
|
-
SYLogger.info(
|
|
325
|
-
f"[Sandbox] 负载均衡: user_id={user_id} -> instance[{instance_index}/{len(instances)}]")
|
|
321
|
+
from sycommon.agent.sandbox.consistent_hash import resolve_user_instance
|
|
322
|
+
instance = resolve_user_instance(user_id, instances)
|
|
326
323
|
else:
|
|
327
324
|
instance = instances[0]
|
|
328
325
|
|
|
@@ -25,6 +25,68 @@ if TYPE_CHECKING:
|
|
|
25
25
|
|
|
26
26
|
logger = logging.getLogger(__name__)
|
|
27
27
|
|
|
28
|
+
|
|
29
|
+
# ==================== ContextOverflowError 识别补丁 ====================
|
|
30
|
+
# MiniMax2.5 等兼容 OpenAI API 的模型返回上下文溢出错误时,
|
|
31
|
+
# 错误格式为 "This model's maximum context length is N tokens...",
|
|
32
|
+
# 而 langchain_openai 的 _handle_openai_bad_request 只识别 OpenAI 原生格式:
|
|
33
|
+
# - "context_length_exceeded"
|
|
34
|
+
# - "Input tokens exceed the configured limit"
|
|
35
|
+
# - "prompt is too long"
|
|
36
|
+
# 导致 MiniMax 的 BadRequestError 不被转换为 ContextOverflowError,
|
|
37
|
+
# SummarizationMiddleware 无法捕获并触发压缩。
|
|
38
|
+
# 此补丁扩展错误模式匹配,使 MiniMax/vLLM 等兼容 API 的溢出错误也被正确识别。
|
|
39
|
+
try:
|
|
40
|
+
import langchain_openai.chat_models.base as _lc_oai_base
|
|
41
|
+
import openai
|
|
42
|
+
from langchain_core.exceptions import ContextOverflowError
|
|
43
|
+
|
|
44
|
+
_orig_handle_bad_request = _lc_oai_base._handle_openai_bad_request
|
|
45
|
+
|
|
46
|
+
def _patched_handle_openai_bad_request(e: openai.BadRequestError) -> None:
|
|
47
|
+
"""扩展版 _handle_openai_bad_request,识别更多上下文溢出错误格式。"""
|
|
48
|
+
error_str = str(e)
|
|
49
|
+
error_msg = getattr(e, 'message', '') or ''
|
|
50
|
+
|
|
51
|
+
# 新增模式:覆盖 MiniMax/vLLM/其他兼容 API 的错误格式
|
|
52
|
+
if (
|
|
53
|
+
"maximum context length is" in error_str
|
|
54
|
+
or "Please reduce the length of the input prompt" in error_msg
|
|
55
|
+
or "maximum context length" in error_msg
|
|
56
|
+
):
|
|
57
|
+
raise _lc_oai_base.OpenAIContextOverflowError(
|
|
58
|
+
message=e.message, response=e.response, body=e.body
|
|
59
|
+
) from e
|
|
60
|
+
|
|
61
|
+
# 回退到原始处理逻辑
|
|
62
|
+
_orig_handle_bad_request(e)
|
|
63
|
+
|
|
64
|
+
_lc_oai_base._handle_openai_bad_request = _patched_handle_openai_bad_request
|
|
65
|
+
|
|
66
|
+
# 同步 patch _handle_openai_api_error
|
|
67
|
+
_orig_handle_api_error = _lc_oai_base._handle_openai_api_error
|
|
68
|
+
|
|
69
|
+
def _patched_handle_openai_api_error(e: openai.APIError) -> None:
|
|
70
|
+
"""扩展版 _handle_openai_api_error,识别更多上下文溢出错误格式。"""
|
|
71
|
+
error_str = str(e)
|
|
72
|
+
if "maximum context length is" in error_str:
|
|
73
|
+
raise _lc_oai_base.OpenAIAPIContextOverflowError(
|
|
74
|
+
message=e.message, request=e.request, body=e.body
|
|
75
|
+
) from e
|
|
76
|
+
_orig_handle_api_error(e)
|
|
77
|
+
|
|
78
|
+
_lc_oai_base._handle_openai_api_error = _patched_handle_openai_api_error
|
|
79
|
+
|
|
80
|
+
logger.info(
|
|
81
|
+
"[ContextOverflowPatch] 已扩展 _handle_openai_bad_request/_handle_openai_api_error "
|
|
82
|
+
"以识别 MiniMax/vLLM 上下文溢出错误格式"
|
|
83
|
+
)
|
|
84
|
+
except ImportError:
|
|
85
|
+
logger.debug("[ContextOverflowPatch] langchain_openai 不可用,跳过补丁")
|
|
86
|
+
except Exception as _patch_err:
|
|
87
|
+
logger.warning(f"[ContextOverflowPatch] 补丁应用失败: {_patch_err}")
|
|
88
|
+
|
|
89
|
+
|
|
28
90
|
# 中文摘要提示词,替代 langchain DEFAULT_SUMMARY_PROMPT
|
|
29
91
|
# 使用纯自然段落格式(无标题/小标题/列表标记),避免结构化标题被模型回显到用户输出
|
|
30
92
|
CUSTOM_SUMMARY_PROMPT = """<role>
|
sycommon/llm/token_utils.py
CHANGED
|
@@ -78,6 +78,11 @@ def clamp_max_tokens(
|
|
|
78
78
|
将 model.max_tokens = min(max_output_tokens, context_window - input - buffer),
|
|
79
79
|
最低保底 min_output。
|
|
80
80
|
|
|
81
|
+
安全措施:
|
|
82
|
+
- context_window 乘以 0.95 作为有效上限,防止模型实际上下文窗口小于配置值
|
|
83
|
+
(如 MiniMax2.5 配置 200000 但实际为 196608)
|
|
84
|
+
- tiktoken 计数可能与模型内部 tokenizer 存在偏差,额外余量可以覆盖
|
|
85
|
+
|
|
81
86
|
Args:
|
|
82
87
|
model: 拥有 ``max_tokens`` 属性的 ChatModel 实例。
|
|
83
88
|
messages: 即将发送给模型的消息列表。
|
|
@@ -89,8 +94,10 @@ def clamp_max_tokens(
|
|
|
89
94
|
Returns:
|
|
90
95
|
调整后的 max_tokens 值。
|
|
91
96
|
"""
|
|
97
|
+
# 有效上下文窗口 = 配置值 * 0.95,防止配置值大于模型实际上下文
|
|
98
|
+
effective_context = int(context_window * 0.95)
|
|
92
99
|
input_est = count_messages_tokens(messages)
|
|
93
|
-
available =
|
|
100
|
+
available = effective_context - input_est - buffer
|
|
94
101
|
dynamic_max = min(max_output_tokens, max(available, min_output))
|
|
95
102
|
model.max_tokens = dynamic_max
|
|
96
103
|
return dynamic_max
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""
|
|
2
|
+
一致性哈希沙箱路由 - 验证测试
|
|
3
|
+
|
|
4
|
+
验证内容:
|
|
5
|
+
1. 用户粘性:同一 user_id 多次调用返回相同实例
|
|
6
|
+
2. 增实例:新增沙箱后只有部分用户迁移
|
|
7
|
+
3. 删实例:删除沙箱后受影响用户被分散
|
|
8
|
+
4. 均匀分布:10000 虚拟槽位均匀分配到各实例
|
|
9
|
+
5. 虚拟槽位固定:user_id 到槽位号永久不变
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
import os
|
|
14
|
+
import random
|
|
15
|
+
import hashlib
|
|
16
|
+
from collections import Counter
|
|
17
|
+
|
|
18
|
+
# 抑制 SYLogger 日志
|
|
19
|
+
os.environ["LOG_LEVEL"] = "ERROR"
|
|
20
|
+
|
|
21
|
+
# 添加路径
|
|
22
|
+
sys.path.insert(0, "/Users/osulcode.xiao/miniconda3/lib/python3.11/site-packages")
|
|
23
|
+
|
|
24
|
+
from sycommon.agent.sandbox.consistent_hash import (
|
|
25
|
+
ConsistentHashMap,
|
|
26
|
+
VIRTUAL_SLOT_COUNT,
|
|
27
|
+
user_to_slot,
|
|
28
|
+
resolve_user_instance,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def make_instances(n: int) -> list[dict]:
|
|
33
|
+
"""生成 n 个模拟沙箱实例"""
|
|
34
|
+
return [{"ip": f"10.0.0.{i+1}", "port": 8080} for i in range(n)]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def instance_key(ins: dict) -> str:
|
|
38
|
+
return f"{ins['ip']}:{ins['port']}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_slot_stability():
|
|
42
|
+
"""测试 1: user_id 到虚拟槽位的映射永久固定"""
|
|
43
|
+
print("=" * 60)
|
|
44
|
+
print("测试 1: user_id → 虚拟槽位 永久固定")
|
|
45
|
+
print("=" * 60)
|
|
46
|
+
|
|
47
|
+
users = [f"user_{i}" for i in range(100)]
|
|
48
|
+
slots_round1 = {u: user_to_slot(u) for u in users}
|
|
49
|
+
slots_round2 = {u: user_to_slot(u) for u in users}
|
|
50
|
+
|
|
51
|
+
assert slots_round1 == slots_round2, "槽位映射不稳定!"
|
|
52
|
+
print(f"✅ {len(users)} 个用户的槽位映射在两次调用中完全一致")
|
|
53
|
+
|
|
54
|
+
# 验证槽位范围
|
|
55
|
+
for u, s in slots_round1.items():
|
|
56
|
+
assert 0 <= s < VIRTUAL_SLOT_COUNT, f"槽位 {s} 超出范围"
|
|
57
|
+
print(f"✅ 所有槽位号在 [0, {VIRTUAL_SLOT_COUNT-1}] 范围内")
|
|
58
|
+
|
|
59
|
+
# 显示几个示例
|
|
60
|
+
print("\n示例映射:")
|
|
61
|
+
for u in users[:5]:
|
|
62
|
+
print(f" {u} → 槽位 {user_to_slot(u)}")
|
|
63
|
+
print()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_user_stickiness():
|
|
67
|
+
"""测试 2: 同一用户在相同实例列表下始终路由到同一实例"""
|
|
68
|
+
print("=" * 60)
|
|
69
|
+
print("测试 2: 用户粘性 - 相同实例列表,路由不变")
|
|
70
|
+
print("=" * 60)
|
|
71
|
+
|
|
72
|
+
instances = make_instances(3)
|
|
73
|
+
users = [f"user_{i}" for i in range(200)]
|
|
74
|
+
|
|
75
|
+
results = []
|
|
76
|
+
for _ in range(5): # 调用 5 次
|
|
77
|
+
round_result = {u: instance_key(resolve_user_instance(u, instances)) for u in users}
|
|
78
|
+
results.append(round_result)
|
|
79
|
+
|
|
80
|
+
# 所有轮次的结果应该完全一致
|
|
81
|
+
for i in range(1, len(results)):
|
|
82
|
+
assert results[0] == results[i], f"第 {i} 轮结果不一致!"
|
|
83
|
+
|
|
84
|
+
print(f"✅ {len(users)} 个用户在 5 次调用中路由完全一致")
|
|
85
|
+
|
|
86
|
+
# 统计分布
|
|
87
|
+
counter = Counter(results[0].values())
|
|
88
|
+
print(f"\n分布: {dict(counter)}")
|
|
89
|
+
print()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_add_instance_minimal_migration():
|
|
93
|
+
"""测试 3: 新增实例时最少迁移"""
|
|
94
|
+
print("=" * 60)
|
|
95
|
+
print("测试 3: 新增沙箱 - 只有部分用户迁移")
|
|
96
|
+
print("=" * 60)
|
|
97
|
+
|
|
98
|
+
users = [f"user_{i}" for i in range(1000)]
|
|
99
|
+
|
|
100
|
+
# 3 个实例
|
|
101
|
+
instances_3 = make_instances(3)
|
|
102
|
+
mapping_3 = {u: instance_key(resolve_user_instance(u, instances_3)) for u in users}
|
|
103
|
+
|
|
104
|
+
# 扩到 4 个实例
|
|
105
|
+
instances_4 = make_instances(4)
|
|
106
|
+
mapping_4 = {u: instance_key(resolve_user_instance(u, instances_4)) for u in users}
|
|
107
|
+
|
|
108
|
+
# 计算迁移数
|
|
109
|
+
migrated = sum(1 for u in users if mapping_3[u] != mapping_4[u])
|
|
110
|
+
migration_rate = migrated / len(users) * 100
|
|
111
|
+
|
|
112
|
+
print(f"3 → 4 实例: {migrated}/{len(users)} 用户迁移 ({migration_rate:.1f}%)")
|
|
113
|
+
print(f"理论最优迁移率: ~{100/4:.1f}% (1/4 的用户应被重新分配到新实例)")
|
|
114
|
+
|
|
115
|
+
# 迁移率应接近 1/N(新增第 4 个实例,理论 ~25% 的用户迁移)
|
|
116
|
+
assert migration_rate < 40, f"迁移率 {migration_rate:.1f}% 过高!"
|
|
117
|
+
print(f"✅ 迁移率 {migration_rate:.1f}% 在合理范围内")
|
|
118
|
+
|
|
119
|
+
# 验证未迁移的用户保持不变
|
|
120
|
+
unchanged = sum(1 for u in users if mapping_3[u] == mapping_4[u])
|
|
121
|
+
print(f"✅ {unchanged}/{len(users)} 用户保持原有沙箱不变")
|
|
122
|
+
|
|
123
|
+
# 扩到 5 个实例
|
|
124
|
+
instances_5 = make_instances(5)
|
|
125
|
+
mapping_5 = {u: instance_key(resolve_user_instance(u, instances_5)) for u in users}
|
|
126
|
+
migrated_4_to_5 = sum(1 for u in users if mapping_4[u] != mapping_5[u])
|
|
127
|
+
rate_4_to_5 = migrated_4_to_5 / len(users) * 100
|
|
128
|
+
print(f"4 → 5 实例: {migrated_4_to_5}/{len(users)} 用户迁移 ({rate_4_to_5:.1f}%)")
|
|
129
|
+
assert rate_4_to_5 < 30, f"迁移率 {rate_4_to_5:.1f}% 过高!"
|
|
130
|
+
print(f"✅ 迁移率 {rate_4_to_5:.1f}% 在合理范围内")
|
|
131
|
+
print()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_remove_instance():
|
|
135
|
+
"""测试 4: 删除实例时受影响用户被分散"""
|
|
136
|
+
print("=" * 60)
|
|
137
|
+
print("测试 4: 删除沙箱 - 受影响用户被分散到其他实例")
|
|
138
|
+
print("=" * 60)
|
|
139
|
+
|
|
140
|
+
users = [f"user_{i}" for i in range(1000)]
|
|
141
|
+
|
|
142
|
+
# 4 个实例
|
|
143
|
+
instances_4 = make_instances(4)
|
|
144
|
+
mapping_4 = {u: instance_key(resolve_user_instance(u, instances_4)) for u in users}
|
|
145
|
+
|
|
146
|
+
# 减到 3 个(删除最后一个)
|
|
147
|
+
instances_3 = make_instances(3)
|
|
148
|
+
mapping_3 = {u: instance_key(resolve_user_instance(u, instances_3)) for u in users}
|
|
149
|
+
|
|
150
|
+
# 被删除实例的用户
|
|
151
|
+
removed_key = "10.0.0.4:8080"
|
|
152
|
+
affected_users = [u for u in users if mapping_4[u] == removed_key]
|
|
153
|
+
print(f"被删除实例 {removed_key} 上有 {len(affected_users)} 个用户")
|
|
154
|
+
|
|
155
|
+
# 这些用户在 3 实例下应该被分散到其余实例
|
|
156
|
+
if affected_users:
|
|
157
|
+
new_destinations = Counter(mapping_3[u] for u in affected_users)
|
|
158
|
+
print(f"迁移分布: {dict(new_destinations)}")
|
|
159
|
+
|
|
160
|
+
# 不应该有任何用户还路由到已删除的实例
|
|
161
|
+
for u in affected_users:
|
|
162
|
+
assert mapping_3[u] != removed_key, f"用户 {u} 仍路由到已删除实例!"
|
|
163
|
+
print(f"✅ 所有受影响用户已迁移到其他实例")
|
|
164
|
+
|
|
165
|
+
# 未受影响的用户保持不变
|
|
166
|
+
unaffected = [u for u in users if mapping_4[u] != removed_key]
|
|
167
|
+
unchanged = sum(1 for u in unaffected if mapping_4[u] == mapping_3[u])
|
|
168
|
+
print(f"✅ {unchanged}/{len(unaffected)} 未受影响用户保持原有沙箱")
|
|
169
|
+
print()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_uniform_distribution():
|
|
173
|
+
"""测试 5: 虚拟槽位均匀分布到各实例"""
|
|
174
|
+
print("=" * 60)
|
|
175
|
+
print("测试 5: 均匀分布 - 虚拟槽位到实例的分配")
|
|
176
|
+
print("=" * 60)
|
|
177
|
+
|
|
178
|
+
for n_instances in [2, 3, 5, 10]:
|
|
179
|
+
instances = make_instances(n_instances)
|
|
180
|
+
ring = ConsistentHashMap()
|
|
181
|
+
ring.build(instances)
|
|
182
|
+
|
|
183
|
+
# 统计所有虚拟槽位的分配
|
|
184
|
+
counter = Counter()
|
|
185
|
+
for slot in range(VIRTUAL_SLOT_COUNT):
|
|
186
|
+
key = ring.get_instance(slot)
|
|
187
|
+
if key:
|
|
188
|
+
counter[key] += 1
|
|
189
|
+
|
|
190
|
+
total = sum(counter.values())
|
|
191
|
+
expected = total / n_instances
|
|
192
|
+
max_deviation = max(abs(v - expected) / expected * 100 for v in counter.values())
|
|
193
|
+
|
|
194
|
+
print(f" {n_instances} 个实例: 每实例期望 {expected:.0f} 槽位, "
|
|
195
|
+
f"最大偏差 {max_deviation:.1f}%")
|
|
196
|
+
print(f" 实际分布: {dict(counter)}")
|
|
197
|
+
|
|
198
|
+
# 偏差应小于 15%
|
|
199
|
+
assert max_deviation < 15, f"偏差 {max_deviation:.1f}% 过大!"
|
|
200
|
+
print(f" ✅ 偏差在合理范围内")
|
|
201
|
+
print()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_large_scale_user_distribution():
|
|
205
|
+
"""测试 6: 大规模用户分布"""
|
|
206
|
+
print("=" * 60)
|
|
207
|
+
print("测试 6: 10000 用户在 5 个沙箱上的分布")
|
|
208
|
+
print("=" * 60)
|
|
209
|
+
|
|
210
|
+
instances = make_instances(5)
|
|
211
|
+
users = [f"user_{i:05d}" for i in range(10000)]
|
|
212
|
+
|
|
213
|
+
counter = Counter()
|
|
214
|
+
for u in users:
|
|
215
|
+
ins = resolve_user_instance(u, instances)
|
|
216
|
+
counter[instance_key(ins)] += 1
|
|
217
|
+
|
|
218
|
+
expected = len(users) / len(instances)
|
|
219
|
+
max_deviation = max(abs(v - expected) / expected * 100 for v in counter.values())
|
|
220
|
+
|
|
221
|
+
print(f"每实例期望: {expected:.0f} 用户")
|
|
222
|
+
print(f"实际分布: {dict(counter)}")
|
|
223
|
+
print(f"最大偏差: {max_deviation:.1f}%")
|
|
224
|
+
assert max_deviation < 15, f"偏差 {max_deviation:.1f}% 过大!"
|
|
225
|
+
print(f"✅ 10000 用户在 5 个沙箱上分布均匀")
|
|
226
|
+
print()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_comparison_with_old_method():
|
|
230
|
+
"""测试 7: 对比新旧方法在增减实例时的迁移率"""
|
|
231
|
+
print("=" * 60)
|
|
232
|
+
print("测试 7: 新(一致性哈希) vs 旧(取模) 方法迁移率对比")
|
|
233
|
+
print("=" * 60)
|
|
234
|
+
|
|
235
|
+
users = [f"user_{i}" for i in range(1000)]
|
|
236
|
+
|
|
237
|
+
def old_method(user_id, instances):
|
|
238
|
+
hash_value = int(hashlib.md5(user_id.encode()).hexdigest(), 16)
|
|
239
|
+
idx = hash_value % len(instances)
|
|
240
|
+
return instance_key(instances[idx])
|
|
241
|
+
|
|
242
|
+
def new_method(user_id, instances):
|
|
243
|
+
return instance_key(resolve_user_instance(user_id, instances))
|
|
244
|
+
|
|
245
|
+
for old_n, new_n in [(3, 4), (4, 5), (5, 6), (10, 11)]:
|
|
246
|
+
old_ins = make_instances(old_n)
|
|
247
|
+
new_ins = make_instances(new_n)
|
|
248
|
+
|
|
249
|
+
# 旧方法迁移率
|
|
250
|
+
old_mapping_before = {u: old_method(u, old_ins) for u in users}
|
|
251
|
+
old_mapping_after = {u: old_method(u, new_ins) for u in users}
|
|
252
|
+
old_migrated = sum(1 for u in users if old_mapping_before[u] != old_mapping_after[u])
|
|
253
|
+
|
|
254
|
+
# 新方法迁移率
|
|
255
|
+
new_mapping_before = {u: new_method(u, old_ins) for u in users}
|
|
256
|
+
new_mapping_after = {u: new_method(u, new_ins) for u in users}
|
|
257
|
+
new_migrated = sum(1 for u in users if new_mapping_before[u] != new_mapping_after[u])
|
|
258
|
+
|
|
259
|
+
print(f" {old_n} → {new_n} 实例:")
|
|
260
|
+
print(f" 旧方法(取模): {old_migrated}/{len(users)} 迁移 ({old_migrated/len(users)*100:.1f}%)")
|
|
261
|
+
print(f" 新方法(一致性): {new_migrated}/{len(users)} 迁移 ({new_migrated/len(users)*100:.1f}%)")
|
|
262
|
+
print(f" 改善: {old_migrated - new_migrated} 个用户免于迁移")
|
|
263
|
+
print()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
print("\n🚀 一致性哈希沙箱路由 - 验证测试\n")
|
|
268
|
+
|
|
269
|
+
test_slot_stability()
|
|
270
|
+
test_user_stickiness()
|
|
271
|
+
test_add_instance_minimal_migration()
|
|
272
|
+
test_remove_instance()
|
|
273
|
+
test_uniform_distribution()
|
|
274
|
+
test_large_scale_user_distribution()
|
|
275
|
+
test_comparison_with_old_method()
|
|
276
|
+
|
|
277
|
+
print("=" * 60)
|
|
278
|
+
print("🎉 所有测试通过!")
|
|
279
|
+
print("=" * 60)
|
|
@@ -129,15 +129,16 @@ sycommon/services.py,sha256=pUcV0xFrn1hrArD9YwE8m6CMrrWPwNBAqUIWhgsVkzY,24943
|
|
|
129
129
|
sycommon/agent/__init__.py,sha256=mxceAeUifQ-DKvWp7ZEJIFlmOCb5wpYHPGQw3rwEN8I,4378
|
|
130
130
|
sycommon/agent/agent_manager.py,sha256=UhhaekEumT7g4v_Z1UB4jTp13X0n8M8erYaQdkGGWkA,13620
|
|
131
131
|
sycommon/agent/chat_events.py,sha256=t7qWa6OrIWLfqtd1AnqaVP67QWkn1JxpCBTZYQpoLuM,14226
|
|
132
|
-
sycommon/agent/deep_agent.py,sha256=
|
|
132
|
+
sycommon/agent/deep_agent.py,sha256=qzRms1paE0bTtMBe1ae7SgQMK_EZEtvqyV8fsUFyxSM,67704
|
|
133
133
|
sycommon/agent/multi_agent_team.py,sha256=mskS-FJbAB_n5kghjHhiWxpA3L9cD1lObeW_wjrLI6s,27079
|
|
134
|
-
sycommon/agent/summarization_utils.py,sha256=
|
|
134
|
+
sycommon/agent/summarization_utils.py,sha256=AjdJmUWIY0kgf1qYvhMMGyB2_v70CEC-SxqSUOU3ZfA,17673
|
|
135
135
|
sycommon/agent/mcp/__init__.py,sha256=iKrdDhIrFsNIkqG_kgcwNe-nOiM6uVfolKv44LfQ-FQ,636
|
|
136
136
|
sycommon/agent/mcp/models.py,sha256=zda4ho-UTYOJ5oPAZPZ-vxhfDRLghUCsMroHnA2A2QQ,1490
|
|
137
137
|
sycommon/agent/mcp/tool_loader.py,sha256=OQa-lE7RL2s3Lqh-kNOKaUrb130hvorB_HjyFBqZBZs,10960
|
|
138
138
|
sycommon/agent/sandbox/__init__.py,sha256=jR7LlkD4J4Y6QYyRXQClkwmqDBCCPmycV_hQV9p9YHw,4621
|
|
139
|
+
sycommon/agent/sandbox/consistent_hash.py,sha256=8Jgk-W4NAD2-u5_vKRVlPmql_e0Vy4aNORiXOuzvIrs,7277
|
|
139
140
|
sycommon/agent/sandbox/file_ops.py,sha256=sqNxlrrzvgJPpHqJB98Us-jsKOYLjal4YUA2Ej7abeU,25852
|
|
140
|
-
sycommon/agent/sandbox/http_sandbox_backend.py,sha256=
|
|
141
|
+
sycommon/agent/sandbox/http_sandbox_backend.py,sha256=sLCbFAwlNRAODr4r4C6QIUxr1NelF50bBIWDE7VOe8w,63141
|
|
141
142
|
sycommon/agent/sandbox/minio_sync.py,sha256=2vc0uw4IVER9F2W-dja1wbZwEy4G8UVTz4kDaBDzWBE,21919
|
|
142
143
|
sycommon/agent/sandbox/sandbox_pool.py,sha256=eMn8sLakCWf90l6ni2-333QM8oBdX1CflV-WzneFp_k,9133
|
|
143
144
|
sycommon/agent/sandbox/sandbox_recovery.py,sha256=X-eDODx1tmGMh_iTngV6e1ppfDBHpTdkPreJusN5MHY,7358
|
|
@@ -189,7 +190,7 @@ sycommon/llm/struct_token.py,sha256=WYNa7o2cC_3X6dJw3ZN1Zgn83HmhdD8GCUlSiEV7qus,
|
|
|
189
190
|
sycommon/llm/sy_langfuse.py,sha256=27RyPUkScqJYHrupOEyUs23fDszoaA66FFgv5Qc7-B4,4443
|
|
190
191
|
sycommon/llm/token_usage_es_service.py,sha256=-RBCwZ8jS436n8DBUBm3Lf5O6TsQMaQgIj5oYBLO4Ss,19909
|
|
191
192
|
sycommon/llm/token_usage_mysql_service.py,sha256=1WhdpZ8M4v4UNP5W0ui3A-Cx5yfZiX73l77FTJvL_d8,32644
|
|
192
|
-
sycommon/llm/token_utils.py,sha256
|
|
193
|
+
sycommon/llm/token_utils.py,sha256=-7cONuu--9yHJQ5kNhLisMFFz8OgFfBfh2pCENpfSUc,3852
|
|
193
194
|
sycommon/llm/usage_token.py,sha256=uFskOhl6IMeJtysJ6tMAmBb2taVLm5lRWjqcrbnkx54,8736
|
|
194
195
|
sycommon/llm/tiktoken_cache/9b5ad71b2ce5302211f9c61530b329a4922fc6a4,sha256=Ijkht27pm96ZW3_3OFE-7xAPtR0YyTWXoRO8_-hlsqc,1681126
|
|
195
196
|
sycommon/llm/tiktoken_cache/fb374d419588a4632f3f557e76b4b70aebbca790,sha256=RGqVOMtsNI41FhINfAiwn1fDZJXirP_-WaW_iwz7Gi0,3613922
|
|
@@ -257,6 +258,7 @@ sycommon/synacos/nacos_service_discovery.py,sha256=LQkMAjpXmsK_STbW9aNk0hdwJJJtG
|
|
|
257
258
|
sycommon/synacos/nacos_service_registration.py,sha256=dR2yn7I7yHbcXUxA-Lj9uy-Oqg6ARdY_Jp_cCS2Sp24,10745
|
|
258
259
|
sycommon/synacos/param.py,sha256=KcfSkxnXOa0TGmCjY8hdzU9pzUsA8-4PeyBKWI2-568,1765
|
|
259
260
|
sycommon/tests/deep_agent_server.py,sha256=t7snT2J6KWZrjJsYVi4TU5jpfvXNz4iuzMk4cc-kQGM,16696
|
|
261
|
+
sycommon/tests/test_consistent_hash.py,sha256=sOHM88LeQCqpXtSjFecv1ylKZ-PsTKAqFTm_V7pHvFQ,10015
|
|
260
262
|
sycommon/tests/test_consumer_loss_rootcause.py,sha256=rP_XdbGbzOM9RB36vk0gU4kqipXG9iHqrCN8rKyd2Aw,16294
|
|
261
263
|
sycommon/tests/test_consumer_recovery.py,sha256=pMMtOwHIijNTZxg3IVBYSGFYgezMX0PUeylks8xzMM8,10364
|
|
262
264
|
sycommon/tests/test_deep_agent.py,sha256=34gP2KL8SSmtqqhaA9OV97OAl_I0T4TfEutZrR_0srM,5874
|
|
@@ -282,8 +284,8 @@ sycommon/tools/syemail.py,sha256=BDFhgf7WDOQeTcjxJEQdu0dQhnHFPO_p3eI0-Ni3LhQ,561
|
|
|
282
284
|
sycommon/tools/timing.py,sha256=OiiE7P07lRoMzX9kzb8sZU9cDb0zNnqIlY5pWqHcnkY,2064
|
|
283
285
|
sycommon/xxljob/__init__.py,sha256=7eoBlQxv-B39IfRSCY2bkqdGYs1QRe1umAWd88VMEEM,86
|
|
284
286
|
sycommon/xxljob/xxljob_service.py,sha256=JIEJaGXhqrTLcyxlyynSrsHg9bBnDNzX-D4qIWLRPUE,6815
|
|
285
|
-
sycommon_python_lib-0.2.
|
|
286
|
-
sycommon_python_lib-0.2.
|
|
287
|
-
sycommon_python_lib-0.2.
|
|
288
|
-
sycommon_python_lib-0.2.
|
|
289
|
-
sycommon_python_lib-0.2.
|
|
287
|
+
sycommon_python_lib-0.2.6a4.dist-info/METADATA,sha256=og-e-U_j2lvFePetXDCzD_KIp4M0yYWh5WoieUyPXbY,7913
|
|
288
|
+
sycommon_python_lib-0.2.6a4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
289
|
+
sycommon_python_lib-0.2.6a4.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
|
|
290
|
+
sycommon_python_lib-0.2.6a4.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
|
|
291
|
+
sycommon_python_lib-0.2.6a4.dist-info/RECORD,,
|
|
File without changes
|
{sycommon_python_lib-0.2.6a2.dist-info → sycommon_python_lib-0.2.6a4.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{sycommon_python_lib-0.2.6a2.dist-info → sycommon_python_lib-0.2.6a4.dist-info}/top_level.txt
RENAMED
|
File without changes
|