feat: refactor API key configuration and enhance application initialization

- Renamed `check_environment` to `check_api_key_configured` for clarity, simplifying the API key validation logic.
- Removed the blocking behavior of the API key check during application startup, allowing the app to run while providing a prompt for configuration.
- Updated `LocalAgentApp` to accept an `api_configured` parameter, enabling conditional messaging for API key setup.
- Enhanced the `SandboxRunner` to support backup management and improved execution result handling with detailed metrics.
- Integrated data governance strategies into the `HistoryManager`, ensuring compliance and improved data management.
- Added privacy settings and metrics tracking across various components to enhance user experience and application safety.
This commit is contained in:
Mimikko-zeus
2026-02-27 14:32:30 +08:00
parent ab5bbff6f7
commit 8a538bb950
58 changed files with 13457 additions and 350 deletions

View File

@@ -12,15 +12,48 @@ import requests
from pathlib import Path
from typing import Optional, Generator, Callable, List, Dict, Any
from dotenv import load_dotenv
import logging
from datetime import datetime
# 获取项目根目录
PROJECT_ROOT = Path(__file__).parent.parent
ENV_PATH = PROJECT_ROOT / ".env"
# 配置日志目录
LOGS_DIR = PROJECT_ROOT / "workspace" / "logs"
LOGS_DIR.mkdir(parents=True, exist_ok=True)
# 配置日志记录器
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
# 创建文件处理器 - 按日期命名
log_file = LOGS_DIR / f"llm_calls_{datetime.now().strftime('%Y%m%d')}.log"
file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_handler.setLevel(logging.DEBUG)
# 设置日志格式
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
file_handler.setFormatter(formatter)
# 添加处理器
logger.addHandler(file_handler)
class LLMClientError(Exception):
"""LLM 客户端异常"""
pass
# 异常类型分类
TYPE_NETWORK = "network" # 网络错误(超时、连接失败等)
TYPE_SERVER = "server" # 服务器错误5xx
TYPE_CLIENT = "client" # 客户端错误4xx
TYPE_PARSE = "parse" # 解析错误
TYPE_CONFIG = "config" # 配置错误
def __init__(self, message: str, error_type: str = TYPE_CLIENT, original_exception: Optional[Exception] = None):
super().__init__(message)
self.error_type = error_type
self.original_exception = original_exception
class LLMClient:
@@ -61,21 +94,38 @@ class LLMClient:
self.max_retries = max_retries
if not self.api_url:
raise LLMClientError("未配置 LLM_API_URL请检查 .env 文件")
raise LLMClientError("未配置 LLM_API_URL请检查 .env 文件", error_type=LLMClientError.TYPE_CONFIG)
if not self.api_key or self.api_key == "your_api_key_here":
raise LLMClientError("未配置有效的 LLM_API_KEY请检查 .env 文件")
raise LLMClientError("未配置有效的 LLM_API_KEY请检查 .env 文件", error_type=LLMClientError.TYPE_CONFIG)
def _should_retry(self, exception: Exception) -> bool:
"""判断是否应该重试"""
# 网络连接错误、超时错误可以重试
"""
判断是否应该重试
可重试的异常类型:
- 网络错误(超时、连接失败)
- 服务器错误5xx
- 限流错误429
"""
# 直接的网络异常(理论上不应该到这里,但保留作为兜底)
if isinstance(exception, (requests.exceptions.ConnectionError,
requests.exceptions.Timeout)):
return True
# 服务器错误5xx可以重试
# LLMClientError 根据错误类型判断
if isinstance(exception, LLMClientError):
error_msg = str(exception)
if "状态码: 5" in error_msg or "502" in error_msg or "503" in error_msg or "504" in error_msg:
# 网络错误和服务器错误可以重试
if exception.error_type in (LLMClientError.TYPE_NETWORK, LLMClientError.TYPE_SERVER):
return True
# 检查原始异常
if exception.original_exception:
if isinstance(exception.original_exception,
(requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.ChunkedEncodingError)):
return True
return False
def _do_request_with_retry(
@@ -85,20 +135,60 @@ class LLMClient:
):
"""带重试的请求执行"""
last_exception = None
retry_count = 0
for attempt in range(self.max_retries + 1):
try:
return request_func()
result = request_func()
# 记录成功的请求(包括重试后成功)
if retry_count > 0:
try:
from llm.config_metrics import get_config_metrics
workspace = PROJECT_ROOT / "workspace"
if workspace.exists():
metrics = get_config_metrics(workspace)
metrics.record_retry_success(retry_count)
except:
pass
return result
except Exception as e:
last_exception = e
# 判断是否应该重试
if attempt < self.max_retries and self._should_retry(e):
retry_count += 1
delay = self.DEFAULT_RETRY_DELAY * (self.DEFAULT_RETRY_BACKOFF ** attempt)
print(f"[重试] {operation_name}失败,{delay:.1f}秒后重试 ({attempt + 1}/{self.max_retries})...")
# 记录重试信息
error_type = getattr(e, 'error_type', 'unknown') if isinstance(e, LLMClientError) else type(e).__name__
print(f"[重试] {operation_name}失败 (错误类型: {error_type}){delay:.1f}秒后重试 ({attempt + 1}/{self.max_retries})...")
# 记录重试次数到配置度量
try:
from llm.config_metrics import get_config_metrics
workspace = PROJECT_ROOT / "workspace"
if workspace.exists():
metrics = get_config_metrics(workspace)
metrics.increment_retry()
except:
pass # 度量记录失败不影响主流程
time.sleep(delay)
continue
else:
# 记录最终失败
if retry_count > 0:
try:
from llm.config_metrics import get_config_metrics
workspace = PROJECT_ROOT / "workspace"
if workspace.exists():
metrics = get_config_metrics(workspace)
metrics.record_retry_failure(retry_count)
except:
pass
raise
# 所有重试都失败
@@ -125,6 +215,22 @@ class LLMClient:
Returns:
LLM 生成的文本内容
"""
# 记录输入 - 完整内容不截断
logger.info("=" * 80)
logger.info(f"LLM 调用 [非流式] - 模型: {model}")
logger.info(f"参数: temperature={temperature}, max_tokens={max_tokens}, timeout={timeout}s")
logger.info(f"时间戳: {datetime.now().isoformat()}")
logger.info("-" * 80)
logger.info("输入消息:")
for i, msg in enumerate(messages):
role = msg.get('role', 'unknown')
content = msg.get('content', '')
logger.info(f" [{i+1}] {role} ({len(content)} 字符):")
# 完整记录,不截断
for line in content.split('\n'):
logger.info(f" {line}")
logger.info("-" * 80)
def do_request():
headers = {
"Authorization": f"Bearer {self.api_key}",
@@ -139,36 +245,85 @@ class LLMClient:
"max_tokens": max_tokens
}
# 记录请求详情
logger.debug(f"API URL: {self.api_url}")
logger.debug(f"请求 Payload: {json.dumps(payload, ensure_ascii=False, indent=2)}")
try:
start_time = time.time()
response = requests.post(
self.api_url,
headers=headers,
json=payload,
timeout=timeout
)
except requests.exceptions.Timeout:
raise LLMClientError(f"请求超时({timeout}秒),请检查网络连接或稍后重试")
except requests.exceptions.ConnectionError:
raise LLMClientError("网络连接失败,请检查网络设置")
elapsed_time = time.time() - start_time
logger.info(f"请求耗时: {elapsed_time:.2f}")
except requests.exceptions.Timeout as e:
logger.error(f"请求超时: {timeout}")
raise LLMClientError(
f"请求超时({timeout}秒),请检查网络连接或稍后重试",
error_type=LLMClientError.TYPE_NETWORK,
original_exception=e
)
except requests.exceptions.ConnectionError as e:
logger.error(f"网络连接失败: {str(e)}")
raise LLMClientError(
"网络连接失败,请检查网络设置",
error_type=LLMClientError.TYPE_NETWORK,
original_exception=e
)
except requests.exceptions.RequestException as e:
raise LLMClientError(f"网络请求异常: {str(e)}")
logger.error(f"网络请求异常: {str(e)}")
raise LLMClientError(
f"网络请求异常: {str(e)}",
error_type=LLMClientError.TYPE_NETWORK,
original_exception=e
)
# 记录响应状态
logger.debug(f"响应状态码: {response.status_code}")
if response.status_code != 200:
error_msg = f"API 返回错误 (状态码: {response.status_code})"
try:
error_detail = response.json()
logger.error(f"错误详情: {json.dumps(error_detail, ensure_ascii=False, indent=2)}")
if "error" in error_detail:
error_msg += f": {error_detail['error']}"
except:
logger.error(f"错误响应: {response.text[:500]}")
error_msg += f": {response.text[:200]}"
raise LLMClientError(error_msg)
# 根据状态码确定错误类型
if response.status_code >= 500:
error_type = LLMClientError.TYPE_SERVER
elif response.status_code == 429:
error_type = LLMClientError.TYPE_SERVER # 限流也可重试
else:
error_type = LLMClientError.TYPE_CLIENT
raise LLMClientError(error_msg, error_type=error_type)
try:
result = response.json()
content = result["choices"][0]["message"]["content"]
# 记录输出 - 完整内容不截断
logger.info("输出响应:")
logger.info(f" 长度: {len(content)} 字符")
for line in content.split('\n'):
logger.info(f" {line}")
logger.info("=" * 80)
return content
except (KeyError, IndexError, TypeError) as e:
raise LLMClientError(f"解析 API 响应失败: {str(e)}")
logger.error(f"解析 API 响应失败: {str(e)}")
logger.error(f"原始响应: {response.text[:1000]}")
raise LLMClientError(
f"解析 API 响应失败: {str(e)}",
error_type=LLMClientError.TYPE_PARSE
)
return self._do_request_with_retry(do_request, "LLM调用")
@@ -193,6 +348,23 @@ class LLMClient:
Yields:
逐个返回生成的文本片段
"""
# 记录输入 - 完整内容不截断
logger.info("=" * 80)
logger.info(f"LLM 调用 [流式] - 模型: {model}")
logger.info(f"参数: temperature={temperature}, max_tokens={max_tokens}, timeout={timeout}s")
logger.info(f"时间戳: {datetime.now().isoformat()}")
logger.info("-" * 80)
logger.info("输入消息:")
for i, msg in enumerate(messages):
role = msg.get('role', 'unknown')
content = msg.get('content', '')
logger.info(f" [{i+1}] {role} ({len(content)} 字符):")
# 完整记录,不截断
for line in content.split('\n'):
logger.info(f" {line}")
logger.info("-" * 80)
logger.info("开始接收流式输出...")
def do_request():
headers = {
"Authorization": f"Bearer {self.api_key}",
@@ -207,7 +379,12 @@ class LLMClient:
"max_tokens": max_tokens
}
# 记录请求详情
logger.debug(f"API URL: {self.api_url}")
logger.debug(f"请求 Payload: {json.dumps(payload, ensure_ascii=False, indent=2)}")
try:
start_time = time.time()
response = requests.post(
self.api_url,
headers=headers,
@@ -215,45 +392,92 @@ class LLMClient:
timeout=timeout,
stream=True
)
except requests.exceptions.Timeout:
raise LLMClientError(f"请求超时({timeout}秒),请检查网络连接或稍后重试")
except requests.exceptions.ConnectionError:
raise LLMClientError("网络连接失败,请检查网络设置")
elapsed_time = time.time() - start_time
logger.info(f"连接建立耗时: {elapsed_time:.2f}")
except requests.exceptions.Timeout as e:
logger.error(f"请求超时: {timeout}")
raise LLMClientError(
f"请求超时({timeout}秒),请检查网络连接或稍后重试",
error_type=LLMClientError.TYPE_NETWORK,
original_exception=e
)
except requests.exceptions.ConnectionError as e:
logger.error(f"网络连接失败: {str(e)}")
raise LLMClientError(
"网络连接失败,请检查网络设置",
error_type=LLMClientError.TYPE_NETWORK,
original_exception=e
)
except requests.exceptions.RequestException as e:
raise LLMClientError(f"网络请求异常: {str(e)}")
logger.error(f"网络请求异常: {str(e)}")
raise LLMClientError(
f"网络请求异常: {str(e)}",
error_type=LLMClientError.TYPE_NETWORK,
original_exception=e
)
# 记录响应状态
logger.debug(f"响应状态码: {response.status_code}")
if response.status_code != 200:
error_msg = f"API 返回错误 (状态码: {response.status_code})"
try:
error_detail = response.json()
logger.error(f"错误详情: {json.dumps(error_detail, ensure_ascii=False, indent=2)}")
if "error" in error_detail:
error_msg += f": {error_detail['error']}"
except:
logger.error(f"错误响应: {response.text[:500]}")
error_msg += f": {response.text[:200]}"
raise LLMClientError(error_msg)
# 根据状态码确定错误类型
if response.status_code >= 500:
error_type = LLMClientError.TYPE_SERVER
elif response.status_code == 429:
error_type = LLMClientError.TYPE_SERVER # 限流也可重试
else:
error_type = LLMClientError.TYPE_CLIENT
raise LLMClientError(error_msg, error_type=error_type)
return response
# 流式请求的重试只在建立连接阶段
response = self._do_request_with_retry(do_request, "流式LLM调用")
# 收集完整输出用于日志
full_output = []
# 解析 SSE 流
for line in response.iter_lines():
if line:
line = line.decode('utf-8')
if line.startswith('data: '):
data = line[6:] # 去掉 "data: " 前缀
if data == '[DONE]':
break
try:
chunk = json.loads(data)
if 'choices' in chunk and len(chunk['choices']) > 0:
delta = chunk['choices'][0].get('delta', {})
content = delta.get('content', '')
if content:
yield content
except json.JSONDecodeError:
continue
try:
for line in response.iter_lines():
if line:
line = line.decode('utf-8')
if line.startswith('data: '):
data = line[6:] # 去掉 "data: " 前缀
if data == '[DONE]':
break
try:
chunk = json.loads(data)
if 'choices' in chunk and len(chunk['choices']) > 0:
delta = chunk['choices'][0].get('delta', {})
content = delta.get('content', '')
if content:
full_output.append(content)
yield content
except json.JSONDecodeError:
continue
except Exception as e:
logger.error(f"流式输出异常: {str(e)}")
raise
# 记录完整输出 - 不截断
complete_output = ''.join(full_output)
logger.info("流式输出完成:")
logger.info(f" 总长度: {len(complete_output)} 字符")
for line in complete_output.split('\n'):
logger.info(f" {line}")
logger.info("=" * 80)
def chat_stream_collect(
self,
@@ -304,3 +528,53 @@ def get_client() -> LLMClient:
if _client is None:
_client = LLMClient()
return _client
def reset_client() -> None:
"""重置 LLM 客户端单例(配置变更后调用)"""
global _client
_client = None
def test_connection(timeout: int = 10) -> tuple[bool, str]:
"""
测试 API 连接是否正常
Args:
timeout: 超时时间(秒)
Returns:
(是否成功, 消息)
"""
try:
client = get_client()
# 发送简单的测试请求
response = client.chat(
messages=[{"role": "user", "content": "hi"}],
model=os.getenv("INTENT_MODEL_NAME") or "Qwen/Qwen2.5-7B-Instruct",
temperature=0.1,
max_tokens=10,
timeout=timeout
)
return (True, "连接成功")
except LLMClientError as e:
error_msg = str(e)
if "未配置" in error_msg or "API Key" in error_msg:
return (False, f"配置错误: {error_msg}")
elif "状态码: 401" in error_msg or "Unauthorized" in error_msg:
return (False, "API Key 无效,请检查配置")
elif "状态码: 403" in error_msg:
return (False, "API Key 权限不足")
elif "状态码: 404" in error_msg:
return (False, "API 地址错误或模型不存在")
elif "网络连接失败" in error_msg:
return (False, "网络连接失败,请检查网络设置")
elif "请求超时" in error_msg:
return (False, f"连接超时({timeout}秒),请检查网络或稍后重试")
else:
return (False, f"连接失败: {error_msg}")
except Exception as e:
return (False, f"未知错误: {str(e)}")

167
llm/config_metrics.py Normal file
View File

@@ -0,0 +1,167 @@
"""
配置变更度量模块
跟踪配置保存后的首次调用成功率和重试次数
"""
import json
from pathlib import Path
from datetime import datetime
from typing import Optional, Dict, Any
from dataclasses import dataclass, asdict
@dataclass
class ConfigChangeMetric:
"""配置变更度量记录"""
timestamp: str
config_changed: bool # 是否发生配置变更
first_call_success: Optional[bool] # 首次调用是否成功
retry_count: int # 重试次数
error_message: Optional[str] # 错误信息
connection_test_success: bool # 保存后连通性测试是否成功
time_to_success_ms: Optional[int] # 从配置变更到首次成功调用的时间(毫秒)
class ConfigMetricsManager:
"""配置度量管理器"""
def __init__(self, metrics_file: Path):
self.metrics_file = metrics_file
self.metrics_file.parent.mkdir(parents=True, exist_ok=True)
# 当前配置变更状态
self._config_changed = False
self._config_change_time: Optional[datetime] = None
self._connection_test_success = False
self._first_call_recorded = False
self._retry_count = 0
def mark_config_changed(self, connection_test_success: bool) -> None:
"""标记配置已变更"""
self._config_changed = True
self._config_change_time = datetime.now()
self._connection_test_success = connection_test_success
self._first_call_recorded = False
self._retry_count = 0
def record_first_call(self, success: bool, error_message: Optional[str] = None) -> None:
"""记录配置变更后的首次调用"""
if not self._config_changed or self._first_call_recorded:
return
time_to_success_ms = None
if self._config_change_time:
delta = datetime.now() - self._config_change_time
time_to_success_ms = int(delta.total_seconds() * 1000)
metric = ConfigChangeMetric(
timestamp=datetime.now().isoformat(),
config_changed=True,
first_call_success=success,
retry_count=self._retry_count,
error_message=error_message,
connection_test_success=self._connection_test_success,
time_to_success_ms=time_to_success_ms
)
self._save_metric(metric)
self._first_call_recorded = True
# 如果成功,重置状态
if success:
self._config_changed = False
self._retry_count = 0
def increment_retry(self) -> None:
"""增加重试计数"""
if self._config_changed:
self._retry_count += 1
def record_retry_success(self, retry_count: int) -> None:
"""记录重试后成功的请求"""
# 可以用于统计重试恢复率
pass
def record_retry_failure(self, retry_count: int) -> None:
"""记录重试后仍失败的请求"""
# 可以用于统计重试失败率
pass
def _save_metric(self, metric: ConfigChangeMetric) -> None:
"""保存度量记录"""
try:
# 读取现有记录
metrics = []
if self.metrics_file.exists():
with open(self.metrics_file, 'r', encoding='utf-8') as f:
metrics = json.load(f)
# 添加新记录
metrics.append(asdict(metric))
# 只保留最近 100 条记录
if len(metrics) > 100:
metrics = metrics[-100:]
# 保存
with open(self.metrics_file, 'w', encoding='utf-8') as f:
json.dump(metrics, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"保存配置度量失败: {e}")
def get_statistics(self) -> Dict[str, Any]:
"""获取统计信息"""
try:
if not self.metrics_file.exists():
return {
"total_config_changes": 0,
"first_call_success_rate": 0.0,
"avg_retry_count": 0.0,
"connection_test_success_rate": 0.0
}
with open(self.metrics_file, 'r', encoding='utf-8') as f:
metrics = json.load(f)
if not metrics:
return {
"total_config_changes": 0,
"first_call_success_rate": 0.0,
"avg_retry_count": 0.0,
"connection_test_success_rate": 0.0
}
total = len(metrics)
success_count = sum(1 for m in metrics if m.get('first_call_success'))
total_retries = sum(m.get('retry_count', 0) for m in metrics)
connection_test_success = sum(1 for m in metrics if m.get('connection_test_success'))
return {
"total_config_changes": total,
"first_call_success_rate": success_count / total if total > 0 else 0.0,
"avg_retry_count": total_retries / total if total > 0 else 0.0,
"connection_test_success_rate": connection_test_success / total if total > 0 else 0.0,
"recent_metrics": metrics[-10:] # 最近 10 条记录
}
except Exception as e:
print(f"获取配置度量统计失败: {e}")
return {
"total_config_changes": 0,
"first_call_success_rate": 0.0,
"avg_retry_count": 0.0,
"connection_test_success_rate": 0.0
}
# 全局单例
_metrics_manager: Optional[ConfigMetricsManager] = None
def get_config_metrics(workspace: Path) -> ConfigMetricsManager:
"""获取配置度量管理器单例"""
global _metrics_manager
if _metrics_manager is None:
metrics_file = workspace / ".metrics" / "config_metrics.json"
_metrics_manager = ConfigMetricsManager(metrics_file)
return _metrics_manager

View File

@@ -444,47 +444,93 @@ REQUIREMENT_CHECK_SYSTEM = """你是一个需求完整性检查器。判断用
2. 明确的操作动作(做什么处理)
3. 关键参数已指定或有合理默认值
【关键信息分类】
- critical_fields: 缺失后无法执行的关键信息(如:水印类型、目标格式、分类依据)
- missing_info: 所有缺失的信息(包括可以使用默认值的)
【严重程度判断】
1. 关键信息缺失is_complete=false, confidence<0.5
- 缺少操作类型(如:不知道是文字水印还是图片水印)
- 缺少必需参数(如:转换格式未指定、分类依据不明)
- 存在多种理解方式且无法确定
2. 一般信息缺失is_complete=false, confidence=0.5-0.7
- 缺少次要参数但有合理默认值(如:透明度、字体大小)
- 描述不够精确但可以推断(如:"整理文件"可推断为按类型分类)
3. 信息完整但置信度低is_complete=true, confidence<0.7
- 所有关键信息都有,但描述模糊
- 可能存在理解偏差
4. 信息完整且置信度高is_complete=true, confidence>=0.7
- 所有关键信息明确
- 描述清晰无歧义
【输出格式】
{
"is_complete": true或false,
"confidence": 0.0到1.0,
"reason": "判断理由",
"critical_fields": ["关键缺失字段1", "关键缺失字段2"], // 仅当存在关键信息缺失时
"missing_info": ["所有缺失信息"],
"suggested_defaults": {
"参数名": "建议的默认值"
}
}
【示例】
【示例1 - 关键信息缺失
输入:"给图片加水印"
输出:
{
"is_complete": false,
"confidence": 0.3,
"reason": "缺少水印类型、内容、位置等关键信息,无法确定用户意图",
"critical_fields": ["水印类型", "水印内容"],
"missing_info": ["水印类型", "水印内容", "水印位置", "透明度"],
"suggested_defaults": {}
}
【示例2 - 一般信息缺失】
输入:"给图片加文字水印,内容是'版权所有'"
输出:
{
"is_complete": false,
"confidence": 0.6,
"reason": "水印类型和内容已明确,但缺少位置信息",
"critical_fields": [],
"missing_info": ["水印位置", "透明度", "字体大小"],
"suggested_defaults": {
"position": "右下角",
"opacity": 50,
"font_size": 24
}
}
【示例3 - 信息完整】
输入:"把图片转成jpg"
输出:
{
"is_complete": true,
"confidence": 0.8,
"reason": "目标格式明确质量可使用默认值85%",
"critical_fields": [],
"missing_info": [],
"suggested_defaults": {
"quality": 85
}
}
输入:"给图片加水印"
出:
{
"is_complete": false,
"confidence": 0.3,
"reason": "缺少水印类型、内容、位置等关键信息",
"suggested_defaults": {}
}
输入:"给图片右下角加上'版权所有'的文字水印"
【示例4 - 信息完整且详细】
入:"给图片右下角加上'版权所有'的白色文字水印透明度50%"
输出:
{
"is_complete": true,
"confidence": 0.9,
"reason": "水印类型、内容、位置都已明确,其他参数可用默认值",
"confidence": 0.95,
"reason": "水印类型、内容、位置、颜色、透明度都已明确",
"critical_fields": [],
"missing_info": [],
"suggested_defaults": {
"opacity": 50,
"font_size": 24,
"color": "white"
"font_size": 24
}
}"""