Files
ai-ticket-platform/backend/apps/agents/services.py
2026-07-27 20:31:41 +08:00

808 lines
31 KiB
Python
Raw 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.
"""Agent 版本校验、运行快照和多步骤执行编排。
运行时只使用已冻结的版本配置,确保模型、Skill、输入映射和授权动作可以事后还原。
"""
import json
import re
from copy import deepcopy
from time import monotonic
from typing import Any
from django.conf import settings
from django.db import transaction
from django.db.models import Max
from django.utils import timezone
from apps.agents.models import (
AgentDefinition,
AgentRun,
AgentRunAttempt,
AgentRunEvent,
AgentRunStep,
AgentStepBinding,
AgentVersion,
)
from apps.audit.models import OperationAuditLog
from apps.modelhub.models import ModelInvocation
from apps.modelhub.services import execute_invocation
from apps.skills.services import (
SkillValidationError,
render_prompt,
validate_json_schema,
validate_payload,
)
SOURCE_PATH = re.compile(
r"^\$(?:input(?:\.[A-Za-z_][A-Za-z0-9_]*)*|steps\.[A-Za-z_][A-Za-z0-9_-]*\.output(?:\.[A-Za-z_][A-Za-z0-9_]*)*)$"
)
class AgentValidationError(ValueError):
pass
@transaction.atomic
def append_run_event(
run: AgentRun | int,
event_type: str,
payload: dict | None = None,
) -> AgentRunEvent:
run_id = run.id if isinstance(run, AgentRun) else run
locked_run = AgentRun.objects.select_for_update().get(id=run_id)
if event_type in {"completed", "failed", "cancelled", "timed_out"}:
existing = locked_run.events.order_by("-sequence").first()
if existing is not None and existing.event_type == event_type:
return existing
last_sequence = locked_run.events.aggregate(value=Max("sequence"))["value"] or 0
event_payload = {
"run_status": locked_run.status,
"progress_stage": locked_run.progress_stage,
"progress_message": locked_run.progress_message,
**(payload or {}),
}
return AgentRunEvent.objects.create(
run=locked_run,
sequence=last_sequence + 1,
event_type=event_type,
payload=event_payload,
)
def _set_run_progress(run: AgentRun, stage: str, message: str) -> None:
run.progress_stage = stage
run.progress_message = message
run.save(update_fields=["progress_stage", "progress_message"])
event_type = stage
if stage == "completed" and run.input_payload.get("draft_public_id"):
event_type = "extraction_completed"
append_run_event(
run,
event_type,
{"current_step": run.current_step, "message": message},
)
def _step_progress(code: str, name: str) -> tuple[str, str]:
if code == "facts":
return "extracting", "正在解析沟通记录"
if code == "draft":
return "generating", "正在生成工单草稿"
return "executing", f"正在执行:{name}"
def validate_agent_version(version: AgentVersion, *, publishing: bool = False) -> None:
"""校验步骤顺序、模型能力、Skill 状态、输入映射和授权动作。"""
validate_json_schema(version.input_schema, "Agent 输入 Schema")
if version.example_input:
validate_payload(version.input_schema, version.example_input, "Agent 示例输入")
if (
not isinstance(version.allowed_actions, list)
or any(
not isinstance(action, str) or not action.strip()
for action in version.allowed_actions
)
or len(set(version.allowed_actions)) != len(version.allowed_actions)
):
raise AgentValidationError("Agent 授权动作必须是无重复的非空字符串列表")
if version.temperature_override is not None and not 0 <= version.temperature_override <= 2:
raise AgentValidationError("Temperature 必须在 0 到 2 之间")
if (
version.max_output_tokens_override is not None
and not 1 <= version.max_output_tokens_override <= 32768
):
raise AgentValidationError("最大输出 Token 超出允许范围")
steps = list(
version.steps.select_related(
"skill_version__definition",
"model_deployment__provider",
"agent_version__model_deployment__provider",
)
)
if not steps:
raise AgentValidationError("Agent 至少需要一个 Skill 步骤")
if len(steps) > 10:
raise AgentValidationError("Agent 最多允许 10 个步骤")
known_steps: set[str] = set()
for step in steps:
deployment = step.model_deployment or version.model_deployment
if deployment.capability not in {"text_chat", "vision_chat"}:
raise AgentValidationError(
f"步骤 {step.code} 必须绑定文本或视觉模型,当前为 {deployment.capability}"
)
if publishing and (not deployment.enabled or not deployment.provider.enabled):
raise AgentValidationError(f"步骤 {step.code} 绑定的模型部署未启用")
if publishing and step.skill_version.status != "published":
raise AgentValidationError(f"步骤 {step.code} 必须绑定已发布 Skill")
for target, source in step.input_mapping.items():
if not isinstance(target, str) or not target:
raise AgentValidationError(f"步骤 {step.code} 的映射字段无效")
if not isinstance(source, str) or not SOURCE_PATH.fullmatch(source):
raise AgentValidationError(f"步骤 {step.code} 的映射来源无效: {source}")
if source.startswith("$steps."):
source_step = source.split(".")[1]
if source_step not in known_steps:
raise AgentValidationError(
f"步骤 {step.code} 只能引用之前步骤的输出: {source_step}"
)
known_steps.add(step.code)
def _resolve_source(path: str, run_input: dict, step_outputs: dict[str, dict]) -> Any:
parts = path.removeprefix("$").split(".")
if parts[0] == "input":
value: Any = run_input
tail = parts[1:]
else:
value = step_outputs.get(parts[1], {}).get("output")
tail = parts[3:]
for part in tail:
if not isinstance(value, dict) or part not in value:
raise AgentValidationError(f"映射来源不存在: {path}")
value = value[part]
return deepcopy(value)
def map_step_input(
binding: AgentStepBinding,
run_input: dict,
step_outputs: dict[str, dict],
) -> dict:
return {
target: _resolve_source(source, run_input, step_outputs)
for target, source in binding.input_mapping.items()
}
def ensure_agent_draft(definition: AgentDefinition, user=None) -> AgentVersion:
existing = definition.versions.filter(status="draft").first()
if existing:
return existing
source = definition.active_version
if source is None:
raise AgentValidationError("Agent 没有可克隆的已发布版本")
latest = definition.versions.order_by("-version").values_list("version", flat=True).first() or 0
draft = AgentVersion.objects.create(
definition=definition,
version=latest + 1,
status="draft",
model_deployment=source.model_deployment,
system_prompt=source.system_prompt,
input_schema=deepcopy(source.input_schema),
example_input=deepcopy(source.example_input),
allowed_actions=deepcopy(source.allowed_actions),
temperature_override=source.temperature_override,
max_output_tokens_override=source.max_output_tokens_override,
created_by=user,
)
AgentStepBinding.objects.bulk_create(
[
AgentStepBinding(
agent_version=draft,
code=step.code,
order=step.order,
skill_version=step.skill_version,
model_deployment=step.model_deployment,
input_mapping=deepcopy(step.input_mapping),
)
for step in source.steps.all()
]
)
return draft
def build_config_snapshot(version: AgentVersion) -> dict:
"""冻结一次运行所需的 Agent、模型、Skill、映射和策略配置。"""
deployment = version.model_deployment
return {
"agent": {
"id": version.definition_id,
"code": version.definition.code,
"version": version.version,
"status": version.status,
"allowed_actions": deepcopy(version.allowed_actions),
},
"model": {
"deployment_id": deployment.id,
"provider_name": deployment.provider.name,
"model_name": deployment.model_name,
"temperature": version.temperature_override
if version.temperature_override is not None
else deployment.default_temperature,
"max_output_tokens": version.max_output_tokens_override or deployment.max_output_tokens,
},
"steps": [
{
"code": step.code,
"order": step.order,
"skill_id": step.skill_version.definition_id,
"skill_code": step.skill_version.definition.code,
"skill_version": step.skill_version.version,
"input_mapping": step.input_mapping,
"model": {
"deployment_id": (
step.model_deployment_id or version.model_deployment_id
),
"inherited": step.model_deployment_id is None,
"provider_name": (
step.model_deployment.provider.name
if step.model_deployment_id
else deployment.provider.name
),
"model_name": (
step.model_deployment.model_name
if step.model_deployment_id
else deployment.model_name
),
"capability": (
step.model_deployment.capability
if step.model_deployment_id
else deployment.capability
),
},
}
for step in version.steps.select_related(
"skill_version__definition",
"model_deployment__provider",
)
],
}
def queue_agent_run(
version: AgentVersion,
input_payload: dict,
*,
created_by=None,
sandbox: bool = False,
config_snapshot_extra: dict | None = None,
defer_until_commit: bool = False,
) -> AgentRun:
"""创建运行及初始事件,并按事务边界决定何时投递 Celery 任务。"""
validate_agent_version(version, publishing=not sandbox)
validate_payload(version.input_schema, input_payload, "Agent 输入")
config_snapshot = build_config_snapshot(version)
if config_snapshot_extra:
config_snapshot.update(deepcopy(config_snapshot_extra))
run = AgentRun.objects.create(
definition=version.definition,
agent_version=version,
sandbox=sandbox,
input_payload=deepcopy(input_payload),
config_snapshot=config_snapshot,
total_steps=version.steps.count(),
created_by=created_by,
)
append_run_event(run, "queued", {"message": "Agent 运行已进入处理队列"})
def dispatch() -> None:
from apps.agents.tasks import execute_agent_run
try:
result = execute_agent_run.delay(run.id)
except Exception as error:
run.status = "failed"
run.progress_stage = "failed"
run.progress_message = "Agent 运行入队失败"
run.error_code = "queue_failed"
run.error_message = str(error)
run.finished_at = timezone.now()
run.save(
update_fields=[
"status",
"progress_stage",
"progress_message",
"error_code",
"error_message",
"finished_at",
]
)
append_run_event(
run,
"failed",
{"error_code": run.error_code, "error_message": run.error_message},
)
return
run.task_id = result.id or ""
run.save(update_fields=["task_id"])
if defer_until_commit:
transaction.on_commit(dispatch)
else:
dispatch()
run.refresh_from_db()
return run
def _parse_json_output(raw_output: str) -> Any:
value = raw_output.strip()
if value.startswith("```"):
value = re.sub(r"^```(?:json)?\s*", "", value)
value = re.sub(r"\s*```$", "", value)
try:
return json.loads(value)
except json.JSONDecodeError as error:
raise SkillValidationError(f"模型输出不是合法 JSON: {error.msg}") from error
def _empty_schema_value(schema: dict) -> Any:
value_type = schema.get("type")
if value_type == "string" and (not schema.get("enum") or "" in schema["enum"]):
return ""
if value_type == "array":
return []
if value_type == "object":
return {}
return None
def _complete_object_shape(value: dict, schema: dict) -> dict:
completed = dict(value)
properties = schema.get("properties", {})
for code in schema.get("required", []):
if code in completed:
continue
empty_value = _empty_schema_value(properties.get(code, {}))
if empty_value is not None:
completed[code] = empty_value
return completed
def _normalize_nested_output(output: Any, output_schema: dict) -> Any:
"""Repair common flattened model output without another model round-trip."""
if not isinstance(output, dict):
return output
properties = output_schema.get("properties", {})
required = set(output_schema.get("required", []))
if {"facts", "evidence", "missing_fields"}.issubset(required):
fact_schema = properties.get("facts", {})
fact_codes = set(fact_schema.get("properties", {}))
nested_facts = output.get("facts")
if isinstance(nested_facts, dict):
facts = _complete_object_shape(nested_facts, fact_schema)
elif fact_codes.intersection(output):
facts = _complete_object_shape(
{code: output[code] for code in fact_codes if code in output},
fact_schema,
)
else:
facts = None
if facts is not None:
evidence = _complete_object_shape(
output.get("evidence", {}) if isinstance(output.get("evidence"), dict) else {},
properties.get("evidence", {}),
)
missing = list(output.get("missing_fields", []))
for code, value in facts.items():
if not value and code not in missing:
missing.append(code)
return {"facts": facts, "evidence": evidence, "missing_fields": missing}
if {"fields", "missing_fields", "questions"}.issubset(required):
field_schema = properties.get("fields", {})
field_codes = set(field_schema.get("properties", {}))
nested_fields = output.get("fields")
if isinstance(nested_fields, dict):
fields = _complete_object_shape(nested_fields, field_schema)
normalized = dict(output)
elif field_codes.intersection(output):
fields = _complete_object_shape(
{code: output[code] for code in field_codes if code in output},
field_schema,
)
normalized = {
code: value
for code, value in output.items()
if code not in field_codes
}
else:
fields = None
if fields is not None:
questions = output.get("questions", output.get("follow_up_questions", []))
normalized.pop("follow_up_questions", None)
normalized.update(
{
"fields": fields,
"missing_fields": list(output.get("missing_fields", [])),
"questions": list(questions) if isinstance(questions, list) else [],
}
)
return normalized
return output
def _record_attempt(
step: AgentRunStep,
invocation: ModelInvocation,
attempt_type: str,
validation_error: str = "",
) -> AgentRunAttempt:
return AgentRunAttempt.objects.create(
step=step,
model_invocation=invocation,
attempt_type=attempt_type,
status=invocation.status,
raw_output=invocation.output_text,
validation_error=validation_error,
input_tokens=invocation.input_tokens or 0,
output_tokens=invocation.output_tokens or 0,
duration_ms=invocation.duration_ms,
)
def _invoke_for_step(
run: AgentRun,
step: AgentRunStep,
prompt: str,
attempt_type: str,
image_parts: list[dict] | None = None,
) -> ModelInvocation:
version = run.agent_version
deployment = step.model_deployment or version.model_deployment
messages = []
if version.system_prompt:
messages.append({"role": "system", "content": version.system_prompt})
user_content: str | list[dict] = prompt
step_image_parts = None
if image_parts and deployment.capability == "vision_chat":
if not deployment.provider.image_egress_approved:
raise AgentValidationError(
f"步骤 {step.code} 的模型渠道未授权图片出境"
)
step_image_parts = image_parts
if step_image_parts:
user_content = [{"type": "text", "text": prompt}, *step_image_parts]
messages.append({"role": "user", "content": user_content})
snapshot_messages = [
{"role": item["role"], "content": prompt if item["role"] == "user" else item["content"]}
for item in messages
]
invocation = ModelInvocation.objects.create(
deployment=deployment,
invocation_type="agent_step",
input_snapshot={
"messages": snapshot_messages,
"image_refs": (
run.input_payload.get("image_refs", []) if step_image_parts else []
),
"run_id": str(run.public_id),
"step": step.code,
},
created_by=run.created_by,
)
if attempt_type == "structure_repair":
_set_run_progress(run, "repairing", "正在修正结构化结果")
def mark_retry(retry_number: int, _error_code: str) -> None:
run.retry_count += 1
run.progress_stage = "retrying"
run.progress_message = f"模型响应暂时不稳定,正在进行第 {retry_number} 次重试"
run.save(update_fields=["retry_count", "progress_stage", "progress_message"])
execute_invocation(
invocation,
messages,
temperature=version.temperature_override,
max_output_tokens=version.max_output_tokens_override,
on_retry=mark_retry,
total_timeout_seconds=settings.AI_AGENT_MODEL_TIMEOUT_SECONDS,
)
if invocation.status == "succeeded":
_set_run_progress(run, "validating", "正在校验结构化结果")
_record_attempt(step, invocation, attempt_type)
if invocation.retry_count:
AgentRunAttempt.objects.create(
step=step,
model_invocation=invocation,
attempt_type="transient_retry",
status=invocation.status,
raw_output=invocation.output_text,
input_tokens=0,
output_tokens=0,
duration_ms=0,
)
return invocation
def _repair_output(
run: AgentRun,
step: AgentRunStep,
raw_output: str,
validation_error: str,
output_schema: dict,
) -> ModelInvocation:
prompt = (
"请只输出修正后的 JSON,不要解释。\n"
f"输出 Schema:{json.dumps(output_schema, ensure_ascii=False)}\n"
f"校验错误:{validation_error}\n"
f"原始输出:{raw_output}"
)
invocation = _invoke_for_step(run, step, prompt, "structure_repair")
attempt = step.attempts.order_by("-created_at").first()
if attempt:
attempt.validation_error = validation_error
attempt.save(update_fields=["validation_error"])
return invocation
def execute_agent_run(run_id: int, *, image_parts: list[dict] | None = None) -> AgentRun:
"""按冻结配置顺序执行所有步骤,记录尝试、产物、进度和终态事件。"""
run = AgentRun.objects.select_related(
"definition",
"agent_version__model_deployment__provider",
"created_by",
).get(id=run_id)
started = monotonic()
run.status = "running"
run.started_at = timezone.now()
run.progress_stage = "starting"
run.progress_message = "正在准备 Agent 运行"
run.save(update_fields=["status", "started_at", "progress_stage", "progress_message"])
append_run_event(run, "starting", {"message": run.progress_message})
step_outputs: dict[str, dict] = {}
try:
bindings = list(
run.agent_version.steps.select_related(
"skill_version__definition",
"model_deployment__provider",
"agent_version__model_deployment__provider",
)
)
for binding in bindings:
run.refresh_from_db(fields=["cancellation_requested_at"])
if run.cancellation_requested_at:
run.status = "cancelled"
_set_run_progress(run, "cancelled", "运行已取消")
break
run.current_step = binding.code
run.save(update_fields=["current_step"])
progress_stage, progress_message = _step_progress(
binding.code,
binding.skill_version.definition.name,
)
_set_run_progress(run, progress_stage, progress_message)
step = AgentRunStep.objects.create(
run=run,
code=binding.code,
name=binding.skill_version.definition.name,
order=binding.order,
skill_version=binding.skill_version,
model_deployment=(
binding.model_deployment or run.agent_version.model_deployment
),
status="running",
started_at=timezone.now(),
)
step_started = monotonic()
try:
step_input = map_step_input(binding, run.input_payload, step_outputs)
validate_payload(
binding.skill_version.input_schema,
step_input,
f"步骤 {binding.code} 输入",
)
step.input_payload = step_input
step.save(update_fields=["input_payload"])
prompt = render_prompt(binding.skill_version.prompt_template, step_input)
invocation = _invoke_for_step(run, step, prompt, "initial", image_parts)
if invocation.status != "succeeded":
raise AgentValidationError(invocation.error_message or "模型调用失败")
try:
output = _parse_json_output(invocation.output_text)
output = _normalize_nested_output(
output,
binding.skill_version.output_schema,
)
validate_payload(binding.skill_version.output_schema, output, "模型输出")
except SkillValidationError as error:
repair = _repair_output(
run,
step,
invocation.output_text,
str(error),
binding.skill_version.output_schema,
)
if repair.status != "succeeded":
raise AgentValidationError(
repair.error_message or "结构修复失败"
) from error
output = _parse_json_output(repair.output_text)
output = _normalize_nested_output(
output,
binding.skill_version.output_schema,
)
validate_payload(binding.skill_version.output_schema, output, "修复输出")
step.output_payload = output
step.status = "succeeded"
step_outputs[binding.code] = {"output": output}
except (AgentValidationError, SkillValidationError) as error:
step.status = "failed"
step.error_code = "step_failed"
step.error_message = str(error)
run.status = "failed"
run.error_code = "step_failed"
run.error_message = f"步骤 {binding.code} 失败: {error}"
_set_run_progress(run, "failed", "Agent 运行失败")
finally:
attempts = step.attempts.all()
step.input_tokens = sum(item.input_tokens for item in attempts)
step.output_tokens = sum(item.output_tokens for item in attempts)
step.duration_ms = max(int((monotonic() - step_started) * 1000), 0)
step.finished_at = timezone.now()
step.save()
if run.status == "failed":
break
run.refresh_from_db(fields=["cancellation_requested_at"])
if run.cancellation_requested_at:
run.status = "cancelled"
_set_run_progress(run, "cancelled", "运行已取消")
break
if run.status == "running":
run.final_output = step.output_payload if bindings else None
try:
from apps.tickets.support_analysis import validate_support_analysis_output
validate_support_analysis_output(run, run.final_output)
from apps.knowledge.distillation import (
validate_knowledge_distillation_output,
)
validate_knowledge_distillation_output(run, run.final_output)
except ValueError as error:
run.status = "failed"
run.error_code = (
"untrusted_support_output"
if run.definition.code == "support-analysis"
else "untrusted_knowledge_distillation_output"
)
run.error_message = str(error)
_set_run_progress(run, "failed", "Agent 结果未通过业务信任校验")
else:
run.status = "succeeded"
_set_run_progress(run, "completed", "草稿生成完成")
except Exception as error:
run.status = "failed"
run.error_code = "runtime_error"
run.error_message = "Agent 运行发生未预期错误"
_set_run_progress(run, "failed", "Agent 运行失败")
OperationAuditLog.objects.create(
user=run.created_by,
module="agents",
action="runtime_error",
target_type="agent_run",
target_id=str(run.public_id),
request_snapshot={"error_type": type(error).__name__},
result="failed",
)
run.input_tokens = sum(item.input_tokens for item in run.steps.all())
run.output_tokens = sum(item.output_tokens for item in run.steps.all())
run.duration_ms = max(int((monotonic() - started) * 1000), 0)
run.finished_at = timezone.now()
run.save()
if run.definition.code == "support-analysis":
from apps.workitems.services import sync_support_analysis_work_item
if run.status == "succeeded":
try:
from apps.tickets.support_analysis import process_support_analysis_result
process_support_analysis_result(run)
except Exception as error:
OperationAuditLog.objects.create(
user=run.created_by,
module="tickets",
action="support_post_processing_failed",
target_type="agent_run",
target_id=str(run.public_id),
request_snapshot={"error_type": type(error).__name__},
result="failed",
)
sync_support_analysis_work_item(
run,
error_code="post_processing_failed",
error_message="支持分析结果后处理失败,请人工接管",
)
else:
sync_support_analysis_work_item(run)
else:
sync_support_analysis_work_item(run)
if run.definition.code == "knowledge-distillation":
from apps.knowledge.models import TicketKnowledgeDistillation
from apps.workitems.services import sync_knowledge_distillation_work_item
try:
distillation = run.ticket_knowledge_distillation
except TicketKnowledgeDistillation.DoesNotExist:
distillation = None
if distillation is not None:
if run.status == "succeeded":
try:
from apps.knowledge.distillation import (
process_knowledge_distillation_result,
)
process_knowledge_distillation_result(run)
except Exception as error:
OperationAuditLog.objects.create(
user=run.created_by,
module="knowledge",
action="knowledge_distillation_post_processing_failed",
target_type="agent_run",
target_id=str(run.public_id),
request_snapshot={"error_type": type(error).__name__},
result="failed",
)
sync_knowledge_distillation_work_item(
distillation.ticket,
run=run,
error_code="post_processing_failed",
error_message="知识蒸馏结果后处理失败,请人工接管",
)
else:
sync_knowledge_distillation_work_item(
distillation.ticket,
run=run,
)
else:
sync_knowledge_distillation_work_item(
distillation.ticket,
run=run,
)
OperationAuditLog.objects.create(
user=run.created_by,
module="agents",
action="run_finished",
target_type="agent_run",
target_id=str(run.public_id),
request_snapshot={"status": run.status, "duration_ms": run.duration_ms},
result="success" if run.status == "succeeded" else run.status,
)
return run
def replace_agent_steps(version: AgentVersion, steps: list[dict]) -> None:
if len(steps) > 10:
raise AgentValidationError("Agent 最多允许 10 个步骤")
with transaction.atomic():
version.steps.all().delete()
AgentStepBinding.objects.bulk_create(
[
AgentStepBinding(
agent_version=version,
code=item["code"],
order=index + 1,
skill_version_id=item["skill_version_id"],
model_deployment_id=item.get("model_deployment_id"),
input_mapping=item.get("input_mapping", {}),
)
for index, item in enumerate(steps)
]
)
validate_agent_version(version)