Files
zhenxun_bot/zhenxun/services/ai/guardrails.py
80fc5b86a7 ✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 (#2146)
* ✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架

- 【重构】将原 services/llm 重构并迁移至全新的 services/ai 架构,提供向下兼容垫片
- 【新增】引入 Agent、Team、Workflow 三大智能体与工作流编排范式
- 【新增】引入基于 RAG 的长期向量记忆与中期槽位记忆系统
- 【新增】引入基于 Docker 的安全代码执行沙箱环境
- 【新增】支持 MCP 协议,允许动态管理和调用 MCP 服务
- 【新增】引入输入输出安全合规护栏与自愈反思机制
- 【优化】重构并优化多厂商 API 适配器 (Gemini, OpenAI, DeepSeek, GLM 等)
- 【优化】优化日志脱敏与 Token 预估机制
- 【移除】移除旧版 llm default 和 llm reset-key 命令,新增 llm mcp 管理命令

* 🔧 chore(deps): 更新项目依赖与配置

- 添加 mcp、jieba 和 aiodocker 依赖到配置文件及 requirements.txt
- 在 pyright 配置中设置 reportMissingImports 为 none
- 调整 .gitignore 中 resources 目录的忽略规则

* ♻️ refactor(tools): 重构工具终止机制并清理知识库日志输出

- 统一使用 `context.state["__end_run__"]` 替代 `EndRunResult` 控制任务结束
- 移除文件系统和向量知识库检索工具中 `ToolResult` 的 `.with_log` 调用
- 调整指令处理器(Directive)的返回值为 `tool_res.output`
- 修复部分类型检查警告并优化联合类型判断语法

* ♻️ refactor(tools): 重构工具副作用指令与控制流熔断机制

- 引入 `DirectivePayload` 及 `ToolResult` 的子类以结构化表达工具副作用
- 移除通过 `context.state` 传递魔术变量的隐式控制流设计
- 重构 `DirectiveManager` 处理器接口,直接在处理器中修改 `AgentState` 并构建 `AgentRunResult`
- 在 `StandardAgentExecutor` 中统一通过 `directive_manager` 调度工具返回的副作用指令
- 补全 `MessageBuilder` 中部分核心方法的文档注释

* 🐛 fix(sandbox): 修复 Docker 沙箱容器状态检测与会话清理逻辑

-【修复】修正 `is_alive` 中直接读取私有属性的问题,改用 `show()` 返回值
-【修复】解决 `execute_code` 中缓存的执行器与当前会话不一致的问题
-【优化】在清理工作区前增加容器存活检测,避免向已死容器发送请求
-【优化】创建容器时增加运行状态校验,若已停止则自动从缓存中移除并重建
-【优化】优化容器销毁和清理逻辑,静默处理容器不存在 (404) 的异常

* 📝 docs(core): 补充核心模块初始化方法的文档注释

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-03 08:53:56 +08:00

432 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from abc import ABC
from collections.abc import Callable
from enum import Enum
import inspect
from typing import Any
from nonebot.utils import is_coroutine_callable
from pydantic import BaseModel, Field
from zhenxun.services.ai.core.exceptions import (
GuardrailFatalException,
GuardrailViolationError,
)
from zhenxun.services.ai.core.messages import ChatResponse, LLMMessage, TextPart
from zhenxun.services.ai.run.context import RunContext
class GuardrailAction(str, Enum):
PASS = "PASS"
"""放行"""
REJECT = "REJECT"
"""致命拦截:直接中断大模型思考"""
REFLECT = "REFLECT"
"""打回反思:触发自愈闭环"""
MUTATE = "MUTATE"
"""数据变异:就地修改数据后放行"""
class GuardrailResult(BaseModel):
"""护栏验证结果的统一载体"""
action: GuardrailAction = GuardrailAction.PASS
"""验证动作(放行、拦截、反思、变异)"""
feedback: str | None = None
"""未通过时的校验失败反馈原因或拒绝理由"""
mutated_text: str | None = None
"""变异后的新文本内容(MUTATE 模式下生效)"""
mutated_obj: Any | None = None
"""变异后的新解析对象(MUTATE 模式下生效)"""
@property
def success(self) -> bool:
return self.action == GuardrailAction.PASS
def input_guardrail(func: Callable | None = None, *, max_attempts: int = 0):
"""显式标记为输入护栏。支持指定最大评估次数 (max_attempts)"""
def decorator(f: Callable):
setattr(f, "__guardrail_type__", "input")
setattr(f, "__guardrail_max_attempts__", max_attempts)
return f
return decorator(func) if func else decorator
def output_guardrail(func: Callable | None = None, *, max_attempts: int = 0):
"""显式标记为输出护栏。支持指定最大评估次数 (max_attempts)"""
def decorator(f: Callable):
setattr(f, "__guardrail_type__", "output")
setattr(f, "__guardrail_max_attempts__", max_attempts)
return f
return decorator(func) if func else decorator
class BaseGuardrail(ABC):
"""大一统的业务逻辑护栏抽象基类"""
max_attempts: int = 0
"""当前护栏在单次上下文中允许触发的最大评估/拦截次数。0 代表无限制。"""
async def validate_input(
self, messages: list[LLMMessage], context: RunContext | None = None
) -> GuardrailResult:
"""执行输入拦截与变异逻辑"""
return GuardrailResult(action=GuardrailAction.PASS)
async def validate_output(
self,
response: ChatResponse | str,
parsed_obj: Any,
context: RunContext | None = None,
) -> GuardrailResult:
"""执行输出反思、拦截与变异逻辑"""
return GuardrailResult(action=GuardrailAction.PASS)
class FunctionalGuardrail(BaseGuardrail):
"""包装普通 Python 函数的智能护栏"""
def __init__(self, func: Callable[..., Any]):
self.func = func
self.guardrail_type = getattr(func, "__guardrail_type__", None)
self.max_attempts = getattr(func, "__guardrail_max_attempts__", 0)
if not self.guardrail_type:
sig = inspect.signature(func)
is_input = False
is_output = False
for param in sig.parameters.values():
if param.annotation == inspect.Parameter.empty:
continue
anno_str = str(param.annotation)
if "LLMMessage" in anno_str:
is_input = True
if "ChatResponse" in anno_str:
is_output = True
if is_input and not is_output:
self.guardrail_type = "input"
elif is_output and not is_input:
self.guardrail_type = "output"
else:
raise ValueError(
"无法自动推断护栏函数 '{func.__name__}' 的作用阶段。\n"
"请使用明确的类型注解 (如 list[LLMMessage] 或 ChatResponse),\n"
"或使用 @input_guardrail / @output_guardrail 装饰器明确声明。"
)
def _bind_core_args(
self, sig: inspect.Signature, core_arg_dict: dict[str, Any]
) -> dict[str, Any]:
"""将框架提供的核心参数按名称或位置绑定到用户的签名上"""
from zhenxun.services.ai.run.di import DependencyInjector
bound_kwargs = {}
unmapped_cores = []
for core_name, core_val in core_arg_dict.items():
if core_name in sig.parameters:
bound_kwargs[core_name] = core_val
else:
unmapped_cores.append(core_val)
if unmapped_cores:
val_idx = 0
for name, param in sig.parameters.items():
if name in ("self", "cls") or name in bound_kwargs:
continue
if DependencyInjector.can_resolve_statically(param):
continue
bound_kwargs[name] = unmapped_cores[val_idx]
val_idx += 1
if val_idx >= len(unmapped_cores):
break
return bound_kwargs
async def _execute_with_di(
self, core_args: dict[str, Any], context: RunContext | None, is_input: bool
) -> GuardrailResult:
"""统一执行带有 DI 依赖注入的护栏逻辑"""
from zhenxun.services.ai.run.di import DependencyInjector
safe_context = context or RunContext()
try:
sig = inspect.signature(self.func)
call_kwargs = self._bind_core_args(sig, core_args)
resolved_kwargs = await DependencyInjector.resolve_all(
sig=sig, call_kwargs=call_kwargs, context=safe_context
)
filtered_kwargs = {
k: v for k, v in resolved_kwargs.items() if k in sig.parameters
}
res = (
await self.func(**filtered_kwargs)
if is_coroutine_callable(self.func)
else self.func(**filtered_kwargs)
)
return self._parse_result(res, is_input=is_input)
except (ValueError, AssertionError) as e:
action = GuardrailAction.REJECT if is_input else GuardrailAction.REFLECT
return GuardrailResult(action=action, feedback=str(e))
except Exception as e:
from zhenxun.services.ai.core.exceptions import ControlFlowExit
if isinstance(e, ControlFlowExit):
raise
action = GuardrailAction.REJECT if is_input else GuardrailAction.REFLECT
stage_str = "输入" if is_input else "输出"
return GuardrailResult(
action=action, feedback=f"{stage_str}护栏执行异常: {e}"
)
async def validate_input(
self, messages: list[LLMMessage], context: RunContext | None = None
) -> GuardrailResult:
if self.guardrail_type != "input":
return GuardrailResult(action=GuardrailAction.PASS)
return await self._execute_with_di(
{"messages": messages}, context, is_input=True
)
async def validate_output(
self,
response: ChatResponse | str,
parsed_obj: Any,
context: RunContext | None = None,
) -> GuardrailResult:
if self.guardrail_type != "output":
return GuardrailResult(action=GuardrailAction.PASS)
return await self._execute_with_di(
{"response": response, "parsed_obj": parsed_obj}, context, is_input=False
)
def _parse_result(self, res: Any, is_input: bool) -> GuardrailResult:
"""统一处理返回值类型推导"""
if isinstance(res, GuardrailResult):
return res
if res is False:
action = GuardrailAction.REJECT if is_input else GuardrailAction.REFLECT
return GuardrailResult(
action=action,
feedback=f"自定义护栏函数 '{self.func.__name__}' 校验未通过",
)
elif isinstance(res, str):
action = GuardrailAction.REJECT if is_input else GuardrailAction.REFLECT
return GuardrailResult(action=action, feedback=res)
return GuardrailResult(action=GuardrailAction.PASS)
class JudgeViolation(BaseModel):
rule: str
"""违反的规则内容"""
reason: str
"""违反该规则的具体原因和证据。如果没有违反,填无"""
class JudgeResponse(BaseModel):
passed: bool
"""文本是否完全遵守了所有的规则。如果有任何一条违反,此处必须为 False"""
violations: list[JudgeViolation] = Field(default_factory=list)
"""违反的规则列表及原因。如果没有违反,返回空列表"""
class LLMJudgeConfig(BaseModel):
"""LLM 裁判的全局设定"""
judge_model: str | None = None
"""指定的裁判模型名称,如果为空则优先使用当前对话模型,其次为全局默认模型"""
system_prompt_template: str | None = None
"""自定义裁判 Prompt 模板。必须包含 {rules} 和 {text} 占位符"""
max_attempts: int = 0
"""最大裁判评估次数。超过该次数后大模型裁判自动放弃并放行 (0 表示无限制)"""
class LLMGuardrail(BaseGuardrail):
"""基于 LLM-as-a-Judge 的自然语言规则裁判护栏"""
def __init__(self, rules: list[str], config: LLMJudgeConfig | None = None):
self.rules = rules
self.config = config or LLMJudgeConfig()
self.max_attempts = self.config.max_attempts
async def validate_output(
self,
response: ChatResponse | str,
parsed_obj: Any,
context: RunContext | None = None,
) -> GuardrailResult:
if not self.rules:
return GuardrailResult(action=GuardrailAction.PASS)
text = response if isinstance(response, str) else response.text
from zhenxun.services.ai.llm.api import generate_structured
from zhenxun.services.ai.llm.manager import get_default_model
rules_str = "\n".join([f"{i + 1}. {r}" for i, r in enumerate(self.rules)])
if self.config.system_prompt_template:
prompt = self.config.system_prompt_template.format(
rules=rules_str, text=text
)
else:
prompt = (
"你是一个严格的内容风控与业务合规裁判。\n"
"请评估以下[待检测内容]是否违反了任何一条[规则列表]。\n\n"
f"### [规则列表]\n{rules_str}\n\n"
f"### [待检测内容]\n{text}\n\n"
"请严格按照规则评估。只要违反了其中任意一条,"
"passed 必须为 false,并在 violations 中详细说明理由。"
)
model = self.config.judge_model
if not model and context and context.run.current_model:
model = context.run.current_model
if not model:
model = get_default_model("chat")
try:
res = await generate_structured(
prompt, response_model=JudgeResponse, model=model
)
if res.passed:
return GuardrailResult(action=GuardrailAction.PASS)
feedbacks = [
f"违反规则: 【{v.rule}】, 原因: {v.reason}" for v in res.violations
]
return GuardrailResult(
action=GuardrailAction.REFLECT, feedback="\n".join(feedbacks)
)
except Exception as e:
return GuardrailResult(
action=GuardrailAction.REFLECT, feedback=f"系统护栏裁判模型异常: {e}"
)
GuardrailSource = Callable[..., Any] | str | BaseGuardrail | LLMJudgeConfig
"""护栏来源(函数、自然语言规则、BaseGuardrail 实例或裁判配置)"""
def parse_guardrails(guardrails: list[GuardrailSource] | None) -> list[BaseGuardrail]:
"""工具方法:将各种类型的 Guardrail 解析为标准的 BaseGuardrail 列表"""
v_list = []
llm_rules = []
judge_config = None
for v in guardrails or []:
if isinstance(v, BaseGuardrail):
v_list.append(v)
elif callable(v):
v_list.append(FunctionalGuardrail(v))
elif isinstance(v, str):
llm_rules.append(v)
elif isinstance(v, LLMJudgeConfig):
judge_config = v
if llm_rules:
v_list.append(LLMGuardrail(rules=llm_rules, config=judge_config))
return v_list
class GuardrailPipeline:
"""护栏管线引擎"""
def __init__(self, guardrails: list[BaseGuardrail]):
self.guardrails = guardrails
async def run_input_pipeline(
self, messages: list[LLMMessage], context: RunContext | None = None
) -> list[LLMMessage]:
"""执行 Input 护栏拦截与变异"""
for g in self.guardrails:
if g.max_attempts > 0 and context:
counts = context.state.setdefault("__guardrail_input_counts__", {})
g_id = id(g)
if counts.get(g_id, 0) >= g.max_attempts:
continue
counts[g_id] = counts.get(g_id, 0) + 1
res = await g.validate_input(messages, context)
if res.action == GuardrailAction.REJECT:
raise GuardrailFatalException(
guard_name=g.__class__.__name__, reason=res.feedback or "输入被拦截"
)
elif (
res.action == GuardrailAction.MUTATE
and res.mutated_text is not None
and messages
):
text_replaced = False
for p in messages[-1].content:
if isinstance(p, TextPart):
p.text = res.mutated_text
text_replaced = True
break
if not text_replaced:
messages[-1].content.append(TextPart(text=res.mutated_text))
return messages
async def run_output_pipeline(
self,
response: ChatResponse | str,
parsed_obj: Any,
context: RunContext | None = None,
) -> tuple[ChatResponse | str, Any]:
"""执行 Output 护栏拦截、反思和变异"""
feedbacks = []
current_response = response
current_obj = parsed_obj
for g in self.guardrails:
if g.max_attempts > 0 and context:
counts = context.state.setdefault("__guardrail_output_counts__", {})
g_id = id(g)
if counts.get(g_id, 0) >= g.max_attempts:
continue
counts[g_id] = counts.get(g_id, 0) + 1
res = await g.validate_output(current_response, current_obj, context)
if res.action == GuardrailAction.REJECT:
raise GuardrailFatalException(
guard_name=g.__class__.__name__, reason=res.feedback or "输出被拒绝"
)
elif res.action == GuardrailAction.MUTATE:
if res.mutated_text is not None:
if isinstance(current_response, str):
current_response = res.mutated_text
else:
text_found = False
for p in current_response.content_parts:
if isinstance(p, TextPart):
p.text = res.mutated_text
text_found = True
break
if not text_found:
current_response.content_parts.insert(
0, TextPart(text=res.mutated_text)
)
if res.mutated_obj is not None:
current_obj = res.mutated_obj
elif res.action == GuardrailAction.REFLECT:
feedbacks.append(res.feedback or f"{g.__class__.__name__} 校验未通过")
if feedbacks:
raise GuardrailViolationError("\n".join(feedbacks))
return current_response, current_obj