fix: v0.3.4 - production stability fixes for simulation pipeline

- LLM JSON truncation: add _repair_truncated_json() to handle max_tokens cutoff
- Ontology generation: increase max_tokens 4096→8192 to prevent truncation
- Config generation: parallel batch processing (3 threads × 30 agents/batch) for ~3x speedup
- Rate limit: reduce semaphore 30→8 to avoid GLM API 429 storms
- HuggingFace offline: set HF_HUB_OFFLINE=1 to skip unreachable hf-mirror.com
- Simulation recovery: auto-reset stuck "preparing" states on Flask restart
- Prepare endpoint: handle concurrent prepare requests gracefully
- Frontend: handle page-refresh-during-prepare edge case
- API: return 429 instead of 500 for LLM rate limit errors
This commit is contained in:
liyizhouAI 2026-04-16 22:44:40 +08:00
parent 3bdaeaf5e7
commit b7df4880c8
13 changed files with 284 additions and 34 deletions

View File

@ -47,6 +47,13 @@ def create_app(config_class=Config):
SimulationRunner.register_cleanup()
if should_log_startup:
logger.info("已注册模拟进程清理函数")
# 启动时恢复卡在 "preparing" 状态的模拟
from .services.simulation_manager import SimulationManager, SimulationStatus
try:
SimulationManager.recover_stuck_preparations()
except Exception as e:
logger.warning(f"恢复卡住的准备任务失败(非致命): {e}")
# 请求日志中间件
@app.before_request

View File

@ -250,13 +250,20 @@ def generate_ontology():
"total_text_length": project.total_text_length
}
})
except Exception as e:
# 区分 429 限流和其他错误
err_str = str(e)
is_rate_limit = any(kw in err_str.lower() for kw in ['429', 'rate limit', '速率限制', '1302', 'too many'])
status_code = 429 if is_rate_limit else 500
logger.error(f"本体生成失败: {type(e).__name__}: {err_str[:300]}")
return jsonify({
"success": False,
"error": str(e),
"error": "LLM 限流,请等待 1-2 分钟后重试" if is_rate_limit else str(e),
"error_type": "rate_limit" if is_rate_limit else "server_error",
"traceback": traceback.format_exc()
}), 500
}), status_code
# ============== 接口2构建图谱 ==============

View File

@ -430,7 +430,7 @@ def prepare_simulation():
# 检查是否强制重新生成
force_regenerate = data.get('force_regenerate', False)
logger.info(f"开始处理 /prepare 请求: simulation_id={simulation_id}, force_regenerate={force_regenerate}")
# 检查是否已经准备完成(避免重复生成)
if not force_regenerate:
logger.debug(f"检查模拟 {simulation_id} 是否已准备完成...")
@ -448,8 +448,23 @@ def prepare_simulation():
"prepare_info": prepare_info
}
})
else:
logger.info(f"模拟 {simulation_id} 未准备完成,将启动准备任务")
# 检查是否正在准备中(避免并发准备)
if state.status == SimulationStatus.PREPARING:
logger.info(f"模拟 {simulation_id} 正在准备中,返回进行中状态")
return jsonify({
"success": True,
"data": {
"simulation_id": simulation_id,
"status": "preparing",
"message": t('api.preparing'),
"already_prepared": False,
"profiles_count": state.profiles_count,
"entities_count": state.entities_count
}
})
logger.info(f"模拟 {simulation_id} 未准备完成,将启动准备任务")
# 从项目获取必要信息
project = ProjectManager.get_project(state.project_id)
@ -744,10 +759,26 @@ def get_prepare_status():
}
})
# 如果没有task_id返回错误
# 如果没有task_id检查模拟状态
if not task_id:
if simulation_id:
# 有simulation_id但未准备完成
# 检查模拟当前状态
sim_state = SimulationManager().get_simulation(simulation_id)
if sim_state and sim_state.status == SimulationStatus.PREPARING:
# 模拟正在准备中可能是页面刷新丢失了task_id
return jsonify({
"success": True,
"data": {
"simulation_id": simulation_id,
"status": "preparing",
"progress": -1, # -1 表示进度未知
"message": t('api.preparing'),
"already_prepared": False,
"profiles_count": sim_state.profiles_count,
"entities_count": sim_state.entities_count
}
})
# 未准备完成且不在准备中
return jsonify({
"success": True,
"data": {

View File

@ -225,12 +225,16 @@ class OntologyGenerator:
{"role": "user", "content": user_message}
]
# max_tokens: 本体 JSON10 实体 + 10 关系 + 属性)需要较大输出空间
# Qwen 32B 尤其需要更大余量,避免截断
ontology_max_tokens = 8192
# 调用 LLM主 LLM 重试 5 次全失败后,自动降级到 fallback
try:
result = self.llm_client.chat_json(
messages=messages,
temperature=0.3,
max_tokens=4096
max_tokens=ontology_max_tokens
)
except Exception as primary_err:
if not self._fallback_llm:
@ -242,7 +246,7 @@ class OntologyGenerator:
result = self._fallback_llm.chat_json(
messages=messages,
temperature=0.3,
max_tokens=4096
max_tokens=ontology_max_tokens
)
# 验证和后处理

View File

@ -212,8 +212,10 @@ class SimulationConfigGenerator:
# 上下文最大字符数
MAX_CONTEXT_LENGTH = 50000
# 每批生成的Agent数量
AGENTS_PER_BATCH = 15
# 每批生成的Agent数量增大以减少LLM调用次数
AGENTS_PER_BATCH = 30
# 并行生成Agent配置的线程数加速配置生成
MAX_PARALLEL_BATCHES = 3
# 各步骤的上下文截断长度(字符数)
TIME_CONFIG_CONTEXT_LENGTH = 10000 # 时间配置
@ -305,25 +307,58 @@ class SimulationConfigGenerator:
event_config = self._parse_event_config(event_config_result)
reasoning_parts.append(f"{t('progress.eventConfigLabel')}: {event_config_result.get('reasoning', t('common.success'))}")
# ========== 步骤3-N: 分批生成Agent配置 ==========
# ========== 步骤3-N: 分批生成Agent配置(并行加速) ==========
all_agent_configs = []
for batch_idx in range(num_batches):
start_idx = batch_idx * self.AGENTS_PER_BATCH
end_idx = min(start_idx + self.AGENTS_PER_BATCH, len(entities))
batch_entities = entities[start_idx:end_idx]
report_progress(
3 + batch_idx,
t('progress.generatingAgentConfig', start=start_idx + 1, end=end_idx, total=len(entities))
)
batch_configs = self._generate_agent_configs_batch(
context=context,
entities=batch_entities,
start_idx=start_idx,
simulation_requirement=simulation_requirement
)
all_agent_configs.extend(batch_configs)
if num_batches <= 1 or len(entities) <= self.AGENTS_PER_BATCH:
# 单批次,直接串行
for batch_idx in range(num_batches):
start_idx = batch_idx * self.AGENTS_PER_BATCH
end_idx = min(start_idx + self.AGENTS_PER_BATCH, len(entities))
batch_entities = entities[start_idx:end_idx]
report_progress(
3 + batch_idx,
t('progress.generatingAgentConfig', start=start_idx + 1, end=end_idx, total=len(entities))
)
batch_configs = self._generate_agent_configs_batch(
context=context,
entities=batch_entities,
start_idx=start_idx,
simulation_requirement=simulation_requirement
)
all_agent_configs.extend(batch_configs)
else:
# 多批次并行生成,加速配置生成
import concurrent.futures
def _gen_batch(batch_idx):
s = batch_idx * self.AGENTS_PER_BATCH
e = min(s + self.AGENTS_PER_BATCH, len(entities))
be = entities[s:e]
report_progress(
3 + batch_idx,
t('progress.generatingAgentConfig', start=s + 1, end=e, total=len(entities))
)
return s, self._generate_agent_configs_batch(
context=context,
entities=be,
start_idx=s,
simulation_requirement=simulation_requirement
)
# 分组并行:每 MAX_PARALLEL_BATCHES 个一批
batch_results = {}
for group_start in range(0, num_batches, self.MAX_PARALLEL_BATCHES):
group_end = min(group_start + self.MAX_PARALLEL_BATCHES, num_batches)
with concurrent.futures.ThreadPoolExecutor(max_workers=self.MAX_PARALLEL_BATCHES) as executor:
futures = {executor.submit(_gen_batch, bi): bi for bi in range(group_start, group_end)}
for future in concurrent.futures.as_completed(futures):
idx, configs = future.result()
batch_results[idx] = configs
# 按 batch 顺序组装结果
for idx in sorted(batch_results.keys()):
all_agent_configs.extend(batch_results[idx])
reasoning_parts.append(t('progress.agentConfigResult', count=len(all_agent_configs)))

View File

@ -144,6 +144,45 @@ class SimulationManager:
def clear_accelerate(cls, simulation_id: str) -> None:
cls._accelerate_flags.pop(simulation_id, None)
@classmethod
def recover_stuck_preparations(cls) -> None:
"""启动时恢复卡在 'preparing' 状态的模拟Flask 重启导致后台线程丢失)"""
logger.info("检查卡住的准备任务...")
data_dir = os.path.join(
os.path.dirname(__file__),
'../../uploads/simulations'
)
if not os.path.exists(data_dir):
return
recovered = 0
for sim_dir_name in os.listdir(data_dir):
sim_dir = os.path.join(data_dir, sim_dir_name)
state_file = os.path.join(sim_dir, "state.json")
if not os.path.isdir(sim_dir) or not os.path.exists(state_file):
continue
try:
with open(state_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if data.get("status") == "preparing":
# 卡在 preparing 状态,重置为 created
data["status"] = "created"
data["error"] = "准备任务因服务重启而中断,请重新准备"
data["updated_at"] = datetime.now().isoformat()
with open(state_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"已重置卡住的准备任务: {sim_dir_name} -> created")
recovered += 1
except Exception as e:
logger.warning(f"恢复准备任务失败 {sim_dir_name}: {e}")
if recovered > 0:
logger.info(f"共恢复 {recovered} 个卡住的准备任务")
else:
logger.info("没有卡住的准备任务")
def __init__(self):
# 确保目录存在
os.makedirs(self.SIMULATION_DATA_DIR, exist_ok=True)

View File

@ -163,5 +163,115 @@ class LLMClient:
try:
return json.loads(cleaned_response)
except json.JSONDecodeError:
raise ValueError(f"LLM返回的JSON格式无效: {cleaned_response}")
# 尝试修复截断的 JSONLLM 输出被 max_tokens 截断时常见)
repaired = self._repair_truncated_json(cleaned_response)
if repaired is not None:
logger.warning("LLM JSON 解析失败,但修复截断后成功")
return repaired
raise ValueError(f"LLM返回的JSON格式无效: {cleaned_response[:500]}")
@staticmethod
def _repair_truncated_json(text: str) -> Optional[Dict[str, Any]]:
"""
尝试修复被截断的 JSON
1. 找到最内层的完整对象/数组闭合外层括号
2. 移除末尾不完整的键值对/元素
"""
# 移除尾部不完整的字符串(引号未闭合)
# 找到最后一个完整的值结束位置
repaired = text.rstrip()
# 策略:逐步回退到最近的有效 JSON 结构
# 1. 移除末尾的逗号和空白
repaired = re.sub(r'[,\s]+$', '', repaired)
# 2. 如果有未闭合的字符串(奇数引号),截断到最后一个完整值
# 向前找最后一个完整 key:value 或 数组元素 的结束位置
for trim_pattern in [
r',\s*"[^"]*$', # 末尾是不完整的 key
r',\s*"[^"]*":\s*$', # 末尾是 key: 但无值
r',\s*"[^"]*":\s*"[^"]*$', # 末尾是不完整的字符串值
r',\s*\{$', # 末尾是空对象开始
r',\s*\[$', # 末尾是空数组开始
r',\s*"[^"]*":\s*\[[^\]]*$', # 末尾是不完整的数组
]:
new_repaired = re.sub(trim_pattern + r'\s*', '', repaired)
if new_repaired != repaired:
repaired = new_repaired
# 3. 闭合所有未闭合的括号
stack = []
i = 0
in_string = False
escape_next = False
while i < len(repaired):
ch = repaired[i]
if escape_next:
escape_next = False
elif ch == '\\' and in_string:
escape_next = True
elif ch == '"' and not escape_next:
in_string = not in_string
elif not in_string:
if ch in '{[':
stack.append(ch)
elif ch == '}':
if stack and stack[-1] == '{':
stack.pop()
elif ch == ']':
if stack and stack[-1] == '[':
stack.pop()
i += 1
# 闭合剩余的括号(逆序)
for bracket in reversed(stack):
if bracket == '{':
repaired += '}'
elif bracket == '[':
repaired += ']'
try:
result = json.loads(repaired)
# 只接受 dict 结果(本体生成期望 dict
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass
# 最后尝试:找到第一个完整的顶层 JSON 对象
depth = 0
best_end = -1
in_str = False
esc = False
for idx, ch in enumerate(text):
if esc:
esc = False
continue
if ch == '\\' and in_str:
esc = True
continue
if ch == '"' and not esc:
in_str = not in_str
continue
if in_str:
continue
if ch == '{':
if depth == 0:
best_end = -1 # reset
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
best_end = idx
break # 第一个完整顶层对象
if best_end > 0:
try:
candidate = json.loads(text[:best_end + 1])
if isinstance(candidate, dict):
return candidate
except json.JSONDecodeError:
pass
return None

View File

@ -32,6 +32,10 @@ OASIS 双平台并行模拟预设脚本
import sys
import os
# Force HuggingFace offline mode - model already cached locally
os.environ['HF_HUB_OFFLINE'] = '1'
os.environ['TRANSFORMERS_OFFLINE'] = '1'
if sys.platform == 'win32':
# 设置 Python 默认 I/O 编码为 UTF-8
# 这会影响所有未指定编码的 open() 调用

View File

@ -578,7 +578,7 @@ class RedditSimulationRunner:
agent_graph=self.agent_graph,
platform=oasis.DefaultPlatformType.REDDIT,
database_path=db_path,
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
semaphore=8, # GLM RPM 有限8 并发避免 429 疯狂触发
)
await self.env.reset()

View File

@ -593,7 +593,7 @@ class TwitterSimulationRunner:
agent_graph=self.agent_graph,
platform=oasis.DefaultPlatformType.TWITTER,
database_path=db_path,
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
semaphore=8, # GLM RPM 有限8 并发避免 429 疯狂触发
)
await self.env.reset()

View File

@ -807,7 +807,18 @@ const startPrepareSimulation = async () => {
await loadPreparedData()
return
}
//
if (res.data.status === 'preparing' && !res.data.task_id) {
addLog(t('log.preparingInprogress') || '模拟环境正在准备中,开始监控进度...')
if (res.data.entities_count) {
expectedTotal.value = res.data.entities_count
}
startPolling()
startProfilesPolling()
return
}
taskId.value = res.data.task_id
addLog(t('log.prepareTaskStarted'))
addLog(t('log.prepareTaskId', { taskId: res.data.task_id }))

View File

@ -349,6 +349,7 @@
"prepareStarted": "Preparation task started. Query progress via /api/simulation/prepare/status.",
"alreadyPrepared": "Preparation already complete. No need to regenerate.",
"notStartedPrepare": "Preparation not started. Please call /api/simulation/prepare.",
"preparing": "Simulation environment is being prepared, please wait...",
"taskCompletedPrepared": "Task completed (preparation already exists)",
"requireTaskOrSimId": "Please provide task_id or simulation_id",
"configNotFound": "Simulation config not found. Please call /prepare first.",

View File

@ -349,6 +349,7 @@
"prepareStarted": "准备任务已启动,请通过 /api/simulation/prepare/status 查询进度",
"alreadyPrepared": "已有完成的准备工作,无需重复生成",
"notStartedPrepare": "尚未开始准备,请调用 /api/simulation/prepare 开始",
"preparing": "模拟环境准备中,请稍候...",
"taskCompletedPrepared": "任务已完成(准备工作已存在)",
"requireTaskOrSimId": "请提供 task_id 或 simulation_id",
"configNotFound": "模拟配置不存在,请先调用 /prepare 接口",