PMFlow/backend/app/services/ai_function_runtime_profile...

580 lines
28 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.

"""Function-level runtime profiles for protected AI calls."""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Any
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config.ai_function_runtime_profiles import (
AI_FUNCTION_RUNTIME_PROFILE_INTEGRATION_TYPE,
DEFAULT_AI_FUNCTION_RUNTIME_PROFILES,
default_runtime_profile_bundle,
)
from ..models import Integration
from ..services.ai_routing_scene_contracts import AI_SCENE_REGISTRY
from ..utils.timezone import now_cn_naive
PLATFORM_RUNTIME_TENANT_ID = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
DEFAULT_SLOT_PROFILE_BUDGETS: dict[str, dict[str, Any]] = {
"low": {
"max_tokens": 2600,
"max_tokens_min": 300,
"max_tokens_max": 8000,
"recommended_min_tokens": 1200,
"repair_max_tokens": 600,
"temperature": 0.1,
"foreground_wait_seconds": 6,
"provider_timeout_seconds": 60,
"job_timeout_seconds": 90,
"max_retries": 0,
"large_input_policy": "direct_generate",
"response_mode": "fast",
"response_format": "text",
},
"high": {
"max_tokens": 6000,
"max_tokens_min": 800,
"max_tokens_max": 12000,
"recommended_min_tokens": 2400,
"repair_max_tokens": 1200,
"temperature": 0.1,
"foreground_wait_seconds": 8,
"provider_timeout_seconds": 120,
"job_timeout_seconds": 240,
"max_retries": 1,
"large_input_policy": "preprocess_then_generate",
"response_mode": "complete",
"response_format": "text",
},
"structured": {
"max_tokens": 8000,
"max_tokens_min": 700,
"max_tokens_max": 12000,
"recommended_min_tokens": 3000,
"repair_max_tokens": 1800,
"temperature": 0.0,
"foreground_wait_seconds": 8,
"provider_timeout_seconds": 90,
"job_timeout_seconds": 180,
"max_retries": 1,
"large_input_policy": "preprocess_then_generate",
"response_mode": "fast",
"response_format": "json_object",
},
"long_context": {
"max_tokens": 8000,
"max_tokens_min": 700,
"max_tokens_max": 16000,
"recommended_min_tokens": 3000,
"repair_max_tokens": 1800,
"temperature": 0.1,
"foreground_wait_seconds": 10,
"provider_timeout_seconds": 180,
"job_timeout_seconds": 300,
"max_retries": 1,
"large_input_policy": "preprocess_then_generate",
"response_mode": "complete",
"response_format": "text",
},
"tool": {
"max_tokens": 3000,
"max_tokens_min": 300,
"max_tokens_max": 8000,
"recommended_min_tokens": 1200,
"repair_max_tokens": 600,
"temperature": 0.0,
"foreground_wait_seconds": 8,
"provider_timeout_seconds": 90,
"job_timeout_seconds": 180,
"max_retries": 0,
"large_input_policy": "direct_generate",
"response_mode": "tool",
"response_format": "json_object",
},
"external_query": {
"max_tokens": 1000,
"max_tokens_min": 1,
"max_tokens_max": 4000,
"recommended_min_tokens": 1,
"repair_max_tokens": 180,
"temperature": 0.0,
"foreground_wait_seconds": 8,
"provider_timeout_seconds": 120,
"job_timeout_seconds": 180,
"max_retries": 0,
"large_input_policy": "direct_generate",
"response_mode": "tool",
"response_format": "external_evidence",
},
}
def _json_clone(value: Any) -> Any:
return json.loads(json.dumps(value, ensure_ascii=False))
def _safe_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _release_version() -> str:
return f"profile-v{now_cn_naive().strftime('%Y%m%d%H%M%S')}"
def _default_profile_for_scene_function(*, scene_key: str, scene: dict[str, Any], function_name: str) -> dict[str, Any]:
required_slots = [str(item or "").strip() for item in list(scene.get("required_slots") or []) if str(item or "").strip()]
default_slot = str(scene.get("default_slot") or scene.get("expected_slot_key") or (required_slots[0] if required_slots else "low")).strip()
if default_slot == "mixed":
default_slot = required_slots[0] if required_slots else "low"
slot_budget = dict(DEFAULT_SLOT_PROFILE_BUDGETS.get(default_slot) or DEFAULT_SLOT_PROFILE_BUDGETS["low"])
response_contract = "structured" if default_slot == "structured" else str(slot_budget.get("response_format") or "text")
if str(scene.get("output_contract") or "").lower().find("json") >= 0:
response_contract = "json"
return {
"function_name": function_name,
"label": function_name,
"scene_key": scene_key,
"prompt_key": "",
"required_slot": default_slot,
"response_contract": response_contract,
**slot_budget,
"retry_policy": {"max_attempts": max(1, int(slot_budget.get("max_retries") or 0) + 1)},
"health_policy": {"window_hours": 24, "consecutive_failure_threshold": 3, "smoke_required_for_publish": False},
"cost_policy": {"budget_class": default_slot, "estimated_cost_ceiling_cny": 0.02, "show_cost_warning_above_tokens": int(slot_budget.get("recommended_min_tokens") or 1000) * 2},
}
@dataclass(frozen=True)
class AIFunctionRuntimeProfile:
function_name: str
scene_key: str
prompt_key: str
required_slot: str
response_contract: str
max_tokens: int
repair_max_tokens: int
temperature: float
foreground_wait_seconds: int
provider_timeout_seconds: int
job_timeout_seconds: int
max_retries: int
large_input_policy: str
response_mode: str
response_format: str
runtime_mode: str
quality_gate_mode: str
profile_version: str
profile_source: str
def routing_meta(self) -> dict[str, Any]:
return {
"function_runtime_profile_version": self.profile_version,
"function_runtime_profile_source": self.profile_source,
"function_runtime_max_tokens": self.max_tokens,
"function_runtime_repair_max_tokens": self.repair_max_tokens,
"foreground_wait_seconds": self.foreground_wait_seconds,
"provider_timeout_seconds": self.provider_timeout_seconds,
"job_timeout_seconds": self.job_timeout_seconds,
"max_retries": self.max_retries,
"large_input_policy": self.large_input_policy,
"runtime_mode": self.runtime_mode,
"quality_gate_mode": self.quality_gate_mode,
"timeout_policy_source": "function_runtime_profile",
"timeout_policy_reason": f"{self.function_name} function runtime profile controls provider/job timeout",
}
class AIFunctionRuntimeProfileService:
"""Load, validate, draft and publish function runtime profiles."""
CONTROLLED_SMOKE_FUNCTIONS = frozenset(
{
"pmflow_launch_draft_v1",
"pmflow_change_review_extraction_v1",
}
)
def __init__(self, db: AsyncSession):
self.db = db
@classmethod
def smoke_supported(cls, function_name: str) -> bool:
return str(function_name or "").strip() in cls.CONTROLLED_SMOKE_FUNCTIONS
async def resolve_profile(
self,
*,
function_name: str,
scene_key: str | None = None,
prompt_key: str | None = None,
stage: str = "active",
) -> AIFunctionRuntimeProfile:
release = await self.get_profiles()
normalized_stage = "draft" if str(stage or "").strip().lower() == "draft" else "active"
profiles = release.get(f"{normalized_stage}_profiles") if isinstance(release.get(f"{normalized_stage}_profiles"), dict) else {}
profile = _safe_dict(profiles.get(function_name)) or _safe_dict(DEFAULT_AI_FUNCTION_RUNTIME_PROFILES.get(function_name))
if not profile:
raise ValueError(f"function runtime profile not found: {function_name}")
normalized = self._normalize_profile(function_name=function_name, raw=profile)
if scene_key and normalized.scene_key != str(scene_key).strip():
raise ValueError(f"function runtime profile scene mismatch: {function_name}")
if prompt_key and normalized.prompt_key != str(prompt_key).strip():
raise ValueError(f"function runtime profile prompt mismatch: {function_name}")
return AIFunctionRuntimeProfile(
function_name=normalized.function_name,
scene_key=normalized.scene_key,
prompt_key=normalized.prompt_key,
required_slot=normalized.required_slot,
response_contract=normalized.response_contract,
max_tokens=normalized.max_tokens,
repair_max_tokens=normalized.repair_max_tokens,
temperature=normalized.temperature,
foreground_wait_seconds=normalized.foreground_wait_seconds,
provider_timeout_seconds=normalized.provider_timeout_seconds,
job_timeout_seconds=normalized.job_timeout_seconds,
max_retries=normalized.max_retries,
large_input_policy=normalized.large_input_policy,
response_mode=normalized.response_mode,
response_format=normalized.response_format,
runtime_mode=normalized.runtime_mode,
quality_gate_mode=normalized.quality_gate_mode,
profile_version=str(release.get(f"{normalized_stage}_version") or release.get("active_version") or "profile-default-v1"),
profile_source=normalized_stage,
)
async def get_profiles(self) -> dict[str, Any]:
bundle = self._normalize_bundle(await self._load_config())
active_profiles = _safe_dict(_safe_dict(bundle.get("active")).get("profiles"))
draft_profiles = _safe_dict(_safe_dict(bundle.get("draft")).get("profiles"))
validation = self.validate_bundle(bundle=bundle, stage="draft")
return {
"version": str(bundle.get("active_version") or "profile-default-v1"),
"active_version": str(bundle.get("active_version") or "profile-default-v1"),
"draft_version": str(bundle.get("draft_version") or "draft"),
"published_at": _safe_dict(bundle.get("active")).get("published_at"),
"published_by": _safe_dict(bundle.get("active")).get("published_by"),
"updated_at": _safe_dict(bundle.get("draft")).get("updated_at"),
"updated_by": _safe_dict(bundle.get("draft")).get("updated_by"),
"dirty": self._bundle_dirty(bundle),
"validation": validation,
"history": bundle.get("history") if isinstance(bundle.get("history"), list) else [],
"draft_last_smoke": _safe_dict(_safe_dict(bundle.get("draft")).get("last_smoke")),
"active_profiles": active_profiles,
"draft_profiles": draft_profiles,
"profiles": draft_profiles,
}
async def update_profile(self, *, function_name: str, payload: dict[str, Any], updated_by: int) -> dict[str, Any]:
integration, bundle = await self._get_or_init_bundle()
draft = _safe_dict(bundle.get("draft"))
profiles = _safe_dict(draft.get("profiles"))
current = _safe_dict(profiles.get(function_name)) or _safe_dict(DEFAULT_AI_FUNCTION_RUNTIME_PROFILES.get(function_name))
if not current:
raise ValueError("函数运行合同不存在")
next_profile = _json_clone(current)
for key in ("max_tokens", "repair_max_tokens"):
if key in payload:
next_profile[key] = int(payload.get(key) or 0)
for key in ("foreground_wait_seconds", "provider_timeout_seconds", "job_timeout_seconds", "max_retries"):
if key in payload:
next_profile[key] = int(payload.get(key) or 0)
for key in ("temperature",):
if key in payload:
next_profile[key] = float(payload.get(key) or 0.0)
for key in ("response_mode", "large_input_policy", "runtime_mode", "quality_gate_mode"):
if key in payload:
next_profile[key] = str(payload.get(key) or "").strip() or current.get(key)
if "required_slot" in payload:
next_profile["required_slot"] = str(payload.get("required_slot") or "").strip() or current.get("required_slot")
self._normalize_profile(function_name=function_name, raw=next_profile)
profiles[function_name] = next_profile
draft["profiles"] = profiles
draft["updated_at"] = now_cn_naive().isoformat()
draft["updated_by"] = int(updated_by)
last_smoke = _safe_dict(draft.get("last_smoke"))
last_smoke.pop(function_name, None)
draft["last_smoke"] = last_smoke
bundle["draft"] = draft
bundle["draft_version"] = "draft"
integration.config = _json_clone(bundle)
integration.is_active = True
await self.db.flush()
return await self.get_profiles()
async def record_smoke(self, *, function_name: str, status: str, summary: str, checked_by: int | None = None) -> dict[str, Any]:
integration, bundle = await self._get_or_init_bundle()
draft = _json_clone(_safe_dict(bundle.get("draft")))
last_smoke = _json_clone(_safe_dict(draft.get("last_smoke")))
last_smoke[function_name] = {
"status": str(status or "unknown"),
"summary": str(summary or "")[:500],
"checked_at": now_cn_naive().isoformat(),
"checked_by": int(checked_by) if checked_by is not None else None,
}
draft["last_smoke"] = last_smoke
bundle["draft"] = draft
integration.config = _json_clone(bundle)
integration.is_active = True
await self.db.flush()
return await self.get_profiles()
async def publish_profiles(self, *, published_by: int, summary: str | None = None) -> dict[str, Any]:
integration, bundle = await self._get_or_init_bundle()
validation = self.validate_bundle(bundle=bundle, stage="draft")
blocking = [item for item in validation.get("items", []) if item.get("severity") == "critical"]
if blocking:
raise ValueError(f"函数运行合同草稿存在阻塞项,不能发布:{self._format_blocking_items(blocking)}")
version = _release_version()
now = now_cn_naive().isoformat()
draft_profiles = _safe_dict(_safe_dict(bundle.get("draft")).get("profiles"))
bundle["active"] = {
"profiles": _json_clone(draft_profiles),
"published_at": now,
"published_by": int(published_by),
}
bundle["active_version"] = version
bundle["version"] = version
history = bundle.get("history") if isinstance(bundle.get("history"), list) else []
history.insert(
0,
{
"version": version,
"published_at": now,
"published_by": int(published_by),
"summary": str(summary or "发布函数运行合同草稿").strip()[:200],
"profiles": _json_clone(draft_profiles),
},
)
bundle["history"] = history[:10]
integration.config = _json_clone(bundle)
integration.is_active = True
await self.db.flush()
return await self.get_profiles()
def validate_bundle(self, *, bundle: dict[str, Any], stage: str = "draft") -> dict[str, Any]:
profiles = _safe_dict(_safe_dict(bundle.get(stage)).get("profiles"))
last_smoke = _safe_dict(_safe_dict(bundle.get("draft")).get("last_smoke"))
items: list[dict[str, Any]] = []
for function_name, raw_profile in profiles.items():
try:
profile = self._normalize_profile(function_name=str(function_name), raw=_safe_dict(raw_profile))
except ValueError as exc:
items.append(self._validation_item(str(function_name), "critical", "profile_invalid", str(exc)))
continue
scene = _safe_dict(AI_SCENE_REGISTRY.get(profile.scene_key))
if profile.function_name not in set(scene.get("function_names") or []):
items.append(self._validation_item(function_name, "critical", "scene_function_mismatch", "函数未登记在对应业务场景合同中。"))
if profile.required_slot not in set(scene.get("required_slots") or []):
items.append(self._validation_item(function_name, "critical", "slot_mismatch", "函数 required_slot 与业务场景必需槽不匹配。"))
default_profile = _safe_dict(self._default_profiles().get(function_name))
recommended_min = int(default_profile.get("recommended_min_tokens") or default_profile.get("max_tokens_min") or 0)
if recommended_min and profile.max_tokens < recommended_min:
items.append(self._validation_item(function_name, "warning", "token_budget_below_recommendation", f"当前 max_tokens={profile.max_tokens},低于建议值 {recommended_min},长结构化草案可能被截断。"))
smoke_required = bool(_safe_dict(profile.__dict__.get("health_policy")).get("smoke_required_for_publish")) if isinstance(profile.__dict__.get("health_policy"), dict) else bool(_safe_dict(default_profile.get("health_policy")).get("smoke_required_for_publish"))
smoke = _safe_dict(last_smoke.get(function_name))
if stage == "draft" and smoke_required and str(smoke.get("status") or "").lower() != "success":
items.append(self._validation_item(function_name, "critical", "smoke_required", "发布前必须运行函数运行合同 smoke,并且结果为 success。"))
critical_count = sum(1 for item in items if item.get("severity") == "critical")
warning_count = sum(1 for item in items if item.get("severity") == "warning")
severity = "critical" if critical_count else ("warning" if warning_count else "ok")
return {
"stage": stage,
"status": "blocked" if severity == "critical" else ("warning" if severity == "warning" else "passed"),
"severity": severity,
"can_publish": critical_count == 0,
"summary": {
"critical": critical_count,
"warning": warning_count,
"ok": max(0, len(profiles) - critical_count - warning_count),
},
"items": items,
}
async def _load_config(self) -> dict[str, Any]:
row = (
await self.db.execute(
select(Integration).where(
and_(
Integration.tenant_id == PLATFORM_RUNTIME_TENANT_ID,
Integration.type == AI_FUNCTION_RUNTIME_PROFILE_INTEGRATION_TYPE,
)
)
)
).scalar_one_or_none()
return _safe_dict(row.config if row is not None else {})
async def _get_or_init_bundle(self) -> tuple[Integration, dict[str, Any]]:
row = (
await self.db.execute(
select(Integration).where(
and_(
Integration.tenant_id == PLATFORM_RUNTIME_TENANT_ID,
Integration.type == AI_FUNCTION_RUNTIME_PROFILE_INTEGRATION_TYPE,
)
)
)
).scalar_one_or_none()
if row is None:
row = Integration(
tenant_id=PLATFORM_RUNTIME_TENANT_ID,
type=AI_FUNCTION_RUNTIME_PROFILE_INTEGRATION_TYPE,
config=default_runtime_profile_bundle(),
is_active=True,
)
self.db.add(row)
await self.db.flush()
bundle = self._normalize_bundle(_safe_dict(row.config))
return row, bundle
@classmethod
def _default_profiles(cls) -> dict[str, dict[str, Any]]:
profiles = _json_clone(DEFAULT_AI_FUNCTION_RUNTIME_PROFILES)
for scene_key, scene in AI_SCENE_REGISTRY.items():
if not isinstance(scene, dict):
continue
for function_name in list(scene.get("function_names") or []):
name = str(function_name or "").strip()
if not name or name in profiles:
continue
profiles[name] = _default_profile_for_scene_function(scene_key=str(scene_key), scene=scene, function_name=name)
return profiles
@classmethod
def _normalize_bundle(cls, raw: dict[str, Any]) -> dict[str, Any]:
default = default_runtime_profile_bundle()
bundle = _safe_dict(raw)
active = _safe_dict(bundle.get("active"))
draft = _safe_dict(bundle.get("draft"))
default_profiles = cls._default_profiles()
active_profiles = _safe_dict(active.get("profiles")) or _safe_dict(bundle.get("profiles")) or default_profiles
draft_profiles = _safe_dict(draft.get("profiles")) or active_profiles
allowed_functions = set(default_profiles)
active_profiles = {
str(function_name): _safe_dict(profile)
for function_name, profile in active_profiles.items()
if str(function_name) in allowed_functions
}
draft_profiles = {
str(function_name): _safe_dict(profile)
for function_name, profile in draft_profiles.items()
if str(function_name) in allowed_functions
}
for function_name, profile in default_profiles.items():
default_profile = _json_clone(profile)
active_profiles[function_name] = {
**default_profile,
**_safe_dict(active_profiles.get(function_name)),
}
draft_profiles[function_name] = {
**default_profile,
**_safe_dict(active_profiles.get(function_name, profile)),
**_safe_dict(draft_profiles.get(function_name)),
}
return {
"version": str(bundle.get("active_version") or bundle.get("version") or default["version"]),
"active_version": str(bundle.get("active_version") or bundle.get("version") or default["active_version"]),
"draft_version": str(bundle.get("draft_version") or "draft"),
"active": {**active, "profiles": _json_clone(active_profiles)},
"draft": {**draft, "profiles": _json_clone(draft_profiles), "last_smoke": _safe_dict(draft.get("last_smoke"))},
"history": bundle.get("history") if isinstance(bundle.get("history"), list) else [],
}
@classmethod
def _normalize_profile(cls, *, function_name: str, raw: dict[str, Any]) -> AIFunctionRuntimeProfile:
profile = {**_safe_dict(cls._default_profiles().get(function_name)), **_safe_dict(raw)}
min_tokens = int(profile.get("max_tokens_min") or 256)
max_tokens_limit = int(profile.get("max_tokens_max") or 12000)
max_tokens = int(profile.get("max_tokens") or 0)
repair_max_tokens = int(profile.get("repair_max_tokens") or 0)
if max_tokens < min_tokens or max_tokens > max_tokens_limit:
raise ValueError(f"max_tokens 必须在 {min_tokens}-{max_tokens_limit} 之间")
if repair_max_tokens < 180 or repair_max_tokens > max_tokens:
raise ValueError("repair_max_tokens 必须不小于 180 且不大于 max_tokens")
temperature = float(profile.get("temperature") or 0.0)
if temperature < 0 or temperature > 1:
raise ValueError("temperature 必须在 0-1 之间")
foreground_wait_seconds = int(profile.get("foreground_wait_seconds") or 0)
provider_timeout_seconds = int(profile.get("provider_timeout_seconds") or 0)
job_timeout_seconds = int(profile.get("job_timeout_seconds") or 0)
max_retries = int(profile.get("max_retries") or 0)
large_input_policy = str(profile.get("large_input_policy") or "preprocess_then_generate").strip()
if foreground_wait_seconds < 3 or foreground_wait_seconds > 300:
raise ValueError("foreground_wait_seconds 必须在 3-300 秒之间")
if provider_timeout_seconds < 10 or provider_timeout_seconds > 300:
raise ValueError("provider_timeout_seconds 必须在 10-300 秒之间")
if job_timeout_seconds < provider_timeout_seconds or job_timeout_seconds > 900:
raise ValueError("job_timeout_seconds 必须不小于 provider_timeout_seconds 且不大于 900 秒")
if max_retries < 0 or max_retries > 3:
raise ValueError("max_retries 必须在 0-3 之间")
if large_input_policy not in {"direct_generate", "preprocess_then_generate"}:
raise ValueError("large_input_policy 仅支持 direct_generate / preprocess_then_generate")
runtime_mode = str(profile.get("runtime_mode") or "active").strip()
quality_gate_mode = str(profile.get("quality_gate_mode") or "strict").strip()
if runtime_mode not in {"active", "shadow", "bypass", "disabled"}:
raise ValueError("runtime_mode 仅支持 active / shadow / bypass / disabled")
if quality_gate_mode not in {"strict", "relaxed", "shadow", "off"}:
raise ValueError("quality_gate_mode 仅支持 strict / relaxed / shadow / off")
scene_key = str(profile.get("scene_key") or "").strip()
scene = _safe_dict(AI_SCENE_REGISTRY.get(scene_key))
required_slot = str(profile.get("required_slot") or "").strip()
allowed_slots = {str(item or "").strip() for item in list(scene.get("allowed_slots") or scene.get("required_slots") or []) if str(item or "").strip()}
if allowed_slots and required_slot not in allowed_slots:
raise ValueError("required_slot 不满足业务场景允许的能力槽")
return AIFunctionRuntimeProfile(
function_name=function_name,
scene_key=scene_key,
prompt_key=str(profile.get("prompt_key") or "").strip(),
required_slot=required_slot,
response_contract=str(profile.get("response_contract") or "structured").strip(),
max_tokens=max_tokens,
repair_max_tokens=repair_max_tokens,
temperature=temperature,
foreground_wait_seconds=foreground_wait_seconds,
provider_timeout_seconds=provider_timeout_seconds,
job_timeout_seconds=job_timeout_seconds,
max_retries=max_retries,
large_input_policy=large_input_policy,
response_mode=str(profile.get("response_mode") or "fast").strip(),
response_format=str(profile.get("response_format") or "json_object").strip(),
runtime_mode=runtime_mode,
quality_gate_mode=quality_gate_mode,
profile_version="",
profile_source="",
)
@staticmethod
def _bundle_dirty(bundle: dict[str, Any]) -> bool:
return _safe_dict(_safe_dict(bundle.get("active")).get("profiles")) != _safe_dict(_safe_dict(bundle.get("draft")).get("profiles"))
@staticmethod
def _validation_item(function_name: str, severity: str, code: str, message: str) -> dict[str, Any]:
return {
"function_name": function_name,
"severity": severity,
"code": code,
"title": function_name,
"message": message,
}
@staticmethod
def _format_blocking_items(items: list[dict[str, Any]]) -> str:
if not items:
return "无明细"
chunks: list[str] = []
for item in items[:5]:
function_name = str(item.get("function_name") or item.get("title") or "unknown_function")
code = str(item.get("code") or "unknown")
message = str(item.get("message") or "存在阻塞项")
chunks.append(f"{function_name} [{code}] {message}")
if len(items) > 5:
chunks.append(f"另有 {len(items) - 5} 项")
return ";".join(chunks)
__all__ = ["AIFunctionRuntimeProfile", "AIFunctionRuntimeProfileService"]