feat: 增加 AI Agent 自主诊断执行模式

This commit is contained in:
cnbugs
2026-07-25 12:21:54 +08:00
parent 26ae846501
commit 4f3a7833a0
8 changed files with 659 additions and 1 deletions
+109
View File
@@ -0,0 +1,109 @@
"""AI Agent 可用的 kubectl 命令工具和安全策略。"""
import asyncio
import shlex
from dataclasses import dataclass
from backend.diagnosis import KUBECTL
READ_ONLY_VERBS = {
"api-resources", "api-versions", "auth", "cluster-info", "describe",
"diff", "explain", "get", "logs", "top", "version", "wait",
}
WRITE_OR_INTERACTIVE_VERBS = {
"annotate", "apply", "attach", "autoscale", "cordon", "cp", "create",
"debug", "delete", "drain", "edit", "exec", "expose", "label", "patch",
"port-forward", "replace", "rollout", "run", "scale", "set", "taint",
"uncordon",
}
GLOBAL_FLAGS_WITH_VALUE = {
"--as", "--as-group", "--as-uid", "--cache-dir", "--certificate-authority",
"--client-certificate", "--client-key", "--cluster", "--context", "--kubeconfig",
"--namespace", "-n", "--profile", "--request-timeout", "--server", "-s",
"--tls-server-name", "--token", "--user",
}
FORBIDDEN_TOKENS = {";", "|", "||", "&&", ">", ">>", "<", "<<", "`", "$"}
@dataclass(frozen=True)
class CommandDecision:
mode: str
requires_approval: bool
verb: str
args: list[str]
def parse_kubectl_command(command: str) -> list[str]:
"""仅解析单条 kubectl 命令,不允许 shell、重定向或命令组合。"""
if not isinstance(command, str) or not command.strip():
raise ValueError("命令不能为空")
if any(token in command for token in FORBIDDEN_TOKENS) or "\n" in command or "\r" in command:
raise ValueError("不允许 shell 操作符、重定向或多条命令")
try:
parts = shlex.split(command)
except ValueError as exc:
raise ValueError(f"命令格式错误: {exc}") from exc
if not parts or parts[0] != "kubectl":
raise ValueError("只允许执行 kubectl 命令")
if len(parts) < 2:
raise ValueError("kubectl 命令缺少操作类型")
return parts[1:]
def _find_verb(args: list[str]) -> str:
index = 0
while index < len(args):
arg = args[index]
if arg == "--":
break
if arg in GLOBAL_FLAGS_WITH_VALUE:
index += 2
continue
if any(arg.startswith(flag + "=") for flag in GLOBAL_FLAGS_WITH_VALUE if flag.startswith("--")):
index += 1
continue
if arg.startswith("-"):
index += 1
continue
return arg.lower()
raise ValueError("无法识别 kubectl 操作类型")
def classify_kubectl_command(command: str) -> CommandDecision:
args = parse_kubectl_command(command)
verb = _find_verb(args)
if verb in READ_ONLY_VERBS:
return CommandDecision("read", False, verb, args)
if verb in WRITE_OR_INTERACTIVE_VERBS:
return CommandDecision("write", True, verb, args)
return CommandDecision("write", True, verb, args)
async def execute_kubectl(command: str, timeout: int = 30) -> dict:
"""无 shell 执行 kubectl,并限制输出大小。"""
decision = classify_kubectl_command(command)
proc = await asyncio.create_subprocess_exec(
KUBECTL,
*decision.args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return {
"command": command,
"mode": decision.mode,
"returncode": proc.returncode,
"stdout": stdout.decode(errors="replace")[:20000].strip(),
"stderr": stderr.decode(errors="replace")[:10000].strip(),
}
except asyncio.TimeoutError:
proc.kill()
await proc.communicate()
return {
"command": command,
"mode": decision.mode,
"returncode": -1,
"stdout": "",
"stderr": f"命令执行超时 ({timeout}s)",
}
+139
View File
@@ -1,7 +1,9 @@
"""AI Agent 分析模块 - 调用 OpenAI 兼容接口分析诊断结果 (支持流式输出)"""
import json
import uuid
import httpx
from backend.config import AI_API_BASE, AI_API_KEY, AI_MODEL
from backend.agent_tools import classify_kubectl_command, execute_kubectl
SYSTEM_PROMPT = """你是一位资深的 Kubernetes 运维专家和 SRE 工程师。你的任务是分析 K8S 集群诊断报告,找出根本原因并给出修复建议。
@@ -20,6 +22,143 @@ _HEADERS = {
}
AGENT_SYSTEM_PROMPT = """你是 Kubernetes 自主诊断 Agent。你可以根据诊断报告决定执行 kubectl 命令获取更多证据,并在证据充分后给出结论。
安全规则:
1. 只允许提出单条 kubectl 命令,不得使用 shell、管道、重定向或命令连接符。
2. get、describe、logs、top 等只读命令可以自动执行。
3. apply、delete、patch、scale、rollout、exec 等修改或交互命令必须暂停并请求人工批准。
4. 不要猜测命令输出;必须依据实际工具结果继续判断。
5. 最多执行有限轮次,优先使用 namespace 和资源名缩小范围。
每轮必须只返回一个 JSON 对象,不要使用 Markdown 代码块:
- 继续调查:{"action":"command","command":"kubectl ...","reason":"为什么执行"}
- 完成分析:{"action":"final","analysis":"中文 Markdown 分析结论"}
"""
_PENDING_APPROVALS: dict[str, dict] = {}
def _parse_agent_action(content: str) -> dict:
try:
action = json.loads(content.strip())
except json.JSONDecodeError as exc:
raise ValueError("AI 未返回合法 JSON 动作") from exc
if not isinstance(action, dict) or action.get("action") not in {"command", "final"}:
raise ValueError("AI 返回了不支持的动作")
if action["action"] == "command" and not action.get("command"):
raise ValueError("AI 命令动作缺少 command")
if action["action"] == "final" and not action.get("analysis"):
raise ValueError("AI 最终动作缺少 analysis")
return action
async def _complete_chat(messages: list[dict]) -> str:
timeout = httpx.Timeout(90, connect=10)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(
f"{AI_API_BASE}/chat/completions",
headers=_HEADERS,
json={
"model": AI_MODEL,
"messages": messages,
"temperature": 0.1,
"max_tokens": 4096,
"stream": False,
},
)
response.raise_for_status()
payload = response.json()
return payload["choices"][0]["message"]["content"]
async def run_diagnostic_agent(
report: str,
question: str = "",
max_steps: int = 6,
execution_mode: str = "ai",
):
"""自主诊断循环:AI 模式执行只读命令,手动模式等待用户执行。"""
if execution_mode not in {"ai", "manual"}:
yield {"type": "error", "message": "不支持的命令执行模式,仅支持 ai 或 manual"}
return
user_content = f"当前 K8S 诊断报告:\n\n{report}"
if question:
user_content += f"\n\n用户关注点:{question}"
messages = [
{"role": "system", "content": AGENT_SYSTEM_PROMPT},
{"role": "user", "content": user_content},
]
for step in range(1, max_steps + 1):
yield {"type": "thinking", "step": step, "message": f"AI 正在规划第 {step} 轮诊断"}
try:
content = await _complete_chat(messages)
action = _parse_agent_action(content)
except Exception as exc:
yield {"type": "error", "message": str(exc)}
return
if action["action"] == "final":
yield {"type": "final", "content": action["analysis"], "model": AI_MODEL}
return
command = action["command"]
reason = action.get("reason", "AI 需要更多集群证据")
try:
decision = classify_kubectl_command(command)
except ValueError as exc:
messages.extend([
{"role": "assistant", "content": content},
{"role": "user", "content": f"命令被安全策略拒绝:{exc}。请改用单条合法 kubectl 命令。"},
])
yield {"type": "command_rejected", "command": command, "reason": str(exc)}
continue
yield {
"type": "command", "step": step, "command": command,
"reason": reason, "mode": decision.mode,
}
if execution_mode == "manual":
yield {
"type": "manual_command", "command_id": uuid.uuid4().hex,
"command": command, "reason": reason, "mode": decision.mode,
"message": "请在终端手动执行命令,并根据结果继续排查",
}
return
if decision.requires_approval:
approval_id = uuid.uuid4().hex
_PENDING_APPROVALS[approval_id] = {
"command": command,
"reason": reason,
"created_by": AI_MODEL,
}
yield {
"type": "approval_required", "approval_id": approval_id,
"command": command, "reason": reason,
}
return
result = await execute_kubectl(command)
yield {"type": "result", **result}
messages.extend([
{"role": "assistant", "content": content},
{"role": "user", "content": "命令实际执行结果:\n" + json.dumps(result, ensure_ascii=False)},
])
yield {
"type": "final",
"content": f"已达到自主诊断最大轮次({max_steps}),请根据上方命令输出继续人工排查。",
"model": AI_MODEL,
}
def take_pending_approval(approval_id: str) -> dict | None:
return _PENDING_APPROVALS.pop(approval_id, None)
async def _stream_chat(messages: list[dict]):
"""通用流式请求 - yield 文本 chunk"""
# connect=10s 快速失败; read=90s 防止 AI 服务无响应时前端无限卡死
+49
View File
@@ -22,7 +22,9 @@ from backend.diagnosis import (
from backend.ai_agent import (
analyze_with_ai, chat_with_ai,
analyze_with_ai_stream, chat_with_ai_stream,
run_diagnostic_agent, take_pending_approval,
)
from backend.agent_tools import execute_kubectl
app = FastAPI(title="K8S 智能诊断平台", version="1.0.0")
@@ -64,6 +66,11 @@ class ChatRequest(BaseModel):
messages: list[dict]
class AgentApprovalRequest(BaseModel):
approval_id: str
approved: bool
@app.get("/api/health")
async def health():
return {"status": "ok", "time": datetime.now().isoformat()}
@@ -250,6 +257,48 @@ async def analyze_stream(question: str = ""):
)
@app.get("/api/agent/diagnose/stream")
async def agent_diagnose_stream(question: str = "", execution_mode: str = "ai"):
"""自主诊断 Agent:支持手动执行和安全 AI 执行模式。"""
if execution_mode not in {"ai", "manual"}:
raise HTTPException(status_code=400, detail="execution_mode 仅支持 ai 或 manual")
global _last_diagnosis, _last_report
if _last_diagnosis is None:
_last_diagnosis = await run_full_diagnosis()
_last_report = build_diagnosis_report(_last_diagnosis)
async def event_generator():
yield f"data: {json.dumps({'type': 'start', 'model': AI_MODEL, 'mode': execution_mode}, ensure_ascii=False)}\n\n"
try:
async for event in run_diagnostic_agent(
_last_report,
question=question,
execution_mode=execution_mode,
):
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'type': 'error', 'message': f'自主诊断失败: {exc}'}, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.post("/api/agent/approve")
async def execute_approved_agent_command(req: AgentApprovalRequest):
"""人工批准后执行一次待审批的修改命令。"""
pending = take_pending_approval(req.approval_id)
if pending is None:
raise HTTPException(status_code=404, detail="审批请求不存在或已经处理")
if not req.approved:
return {"approved": False, "command": pending["command"], "message": "用户已拒绝执行"}
result = await execute_kubectl(pending["command"])
return {"approved": True, "reason": pending["reason"], **result}
@app.post("/api/chat")
async def chat(req: ChatRequest):
"""AI 多轮对话 (非流式,兼容旧接口)"""