feat(platform): extract规格会话级锚定(Redis db4,键=pipeline:user:session隔离)+apply双工具use_last_extract/overrides白名单——根治LLM转述大JSON必编造结构(实测三轮编造);报错升级列期望键给出路;提示词加自救规则

This commit is contained in:
yumoqing 2026-09-05 21:25:48 +08:00
parent 885a52c7d8
commit fb3447e56e

View File

@ -44,6 +44,106 @@ OWNER_ORG = "owner"
# 其他协议的适配模板会存入 llm_api_profile 备查,但运行时暂不渲染。
RUNTIME_PROTOCOLS = ("openai_compat", "dashscope_async")
# ────────────────────── 会话级规格缓存extract 锚定2026-09-05──────────────────────
# 根因apply_llm_config 要求 LLM 把 extract 返回的大 JSON 规格逐字复制进参数,
# 模型「转述」大 JSON 必然编造结构(实测三轮全编造 vendor{}/api_profile{} 等契约外字段)。
# 对策extract 成功后把规格锚定到「会话级缓存」apply/apply_model_pricing 用
# use_last_extract=true 直接取用 + 少量覆盖项vendor_name 等LLM 永不搬运大 JSON。
#
# 会话级隔离(用户硬要求):缓存键 = pipeline_id:user_id:session_id三段缺一不可
# 防跨会话/跨用户/跨产线串数据。存 Redis db4治理命名空间gateway 已在用),
# 跨 worker 进程不丢TTL 兜底过期。Redis 不可用时降级进程内字典(仍按同键隔离)。
_SPEC_CACHE_TTL = 7200 # 规格缓存 2 小时(一次配置会话足够)
_SPEC_CACHE_PREFIX = "llm_spec"
_spec_cache_local = {} # Redis 不可用时的进程内降级(同样按隔离键)
def _spec_cache_key(ctx):
"""会话级隔离键pipeline_id:user_id:session_id。任一段缺失返回 ''(拒绝缓存)。"""
pl = (ctx.get("pipeline_id") or "").strip()
uid = (ctx.get("user_id") or "").strip()
sid = (ctx.get("session_id") or "").strip()
if not (pl and uid and sid):
return ''
return "%s:%s:%s:%s" % (_SPEC_CACHE_PREFIX, pl, uid, sid)
def _spec_redis():
"""复用治理命名空间 Redis db4与 gateway 限流同库,会话态用 db3 不冲突)。"""
try:
import redis as _r
try:
from appPublic.jsonConfig import getConfig
url = (getConfig().website or {}).get('session_redis', {}).get('url', '')
except Exception:
url = ''
if not url:
url = 'redis://127.0.0.1:6379/3'
base = url.rsplit('/', 1)[0]
return _r.Redis.from_url(base + '/4', socket_timeout=2)
except Exception:
return None
def _spec_save(ctx, spec):
"""锚定规格到会话缓存。返回 True/False。无隔离键则不缓存apply 会要求显式传 spec"""
key = _spec_cache_key(ctx)
if not key:
return False
try:
payload = json.dumps(spec, ensure_ascii=False)
except Exception:
return False
r = _spec_redis()
if r is not None:
try:
r.set(key, payload, ex=_SPEC_CACHE_TTL)
return True
except Exception:
pass
_spec_cache_local[key] = (payload, time.time() + _SPEC_CACHE_TTL)
return True
def _spec_load(ctx):
"""取本会话锚定的规格。无则返回 None。严格会话级隔离键含 session_id"""
key = _spec_cache_key(ctx)
if not key:
return None
r = _spec_redis()
raw = None
if r is not None:
try:
v = r.get(key)
raw = v.decode('utf-8') if isinstance(v, (bytes, bytearray)) else v
except Exception:
raw = None
if raw is None:
ent = _spec_cache_local.get(key)
if ent and ent[1] > time.time():
raw = ent[0]
elif ent:
_spec_cache_local.pop(key, None)
if not raw:
return None
try:
return json.loads(raw)
except Exception:
return None
def _spec_overlay(base, overrides):
"""覆盖项合并仅允许少量标量覆盖vendor_name 等),不做大 JSON 搬运。
overrides 只认白名单键防止 LLM 借覆盖项塞回编造结构
"""
allowed = {'vendor_name', 'base_url', 'protocol', 'doc_url', 'doc_notes'}
out = dict(base or {})
for k in allowed:
if k in (overrides or {}) and str(overrides.get(k) or '').strip():
out[k] = str(overrides[k]).strip()
return out
# ────────────────────── 权限门禁(代码层) ──────────────────────
@ -297,7 +397,18 @@ async def _h_extract_llm_api_spec(sor, params, ctx):
spec = json.loads(m.group(0))
except Exception:
return "LLM 输出不是合法 JSON%s" % txt[:300]
return json.dumps(spec, ensure_ascii=False)
# 会话级锚定2026-09-05规格存会话缓存键含 session_id跨会话隔离
# 后续 apply_llm_config / apply_model_pricing 用 use_last_extract=true 取用——
# LLM 不再搬运/转述大 JSON转述必编造结构实测三轮全编造
anchored = _spec_save(ctx, spec)
out = dict(spec)
if anchored:
out["__anchored__"] = ("规格已锚定到本会话缓存。下一步调用 apply_llm_config 与 "
"apply_model_pricing 时传 {\"use_last_extract\": true}"
"需改动只传覆盖项overrides仅 vendor_name/base_url/"
"protocol/doc_url/doc_notes 白名单键)。"
"禁止把本规格复制进 spec 参数——复制即编造。")
return json.dumps(out, ensure_ascii=False)
# ────────────────────── 工具 4写入配置幂等 ──────────────────────
@ -310,21 +421,52 @@ def _f(v, default=0.0):
async def _h_apply_llm_config(sor, params, ctx):
"""按提取的规格写库:供应商(复用/新建)+ 适配模板 + 模型(含定价)
"""按提取的规格写库:供应商(复用/新建)+ 适配模板 + 模型
幂等供应商按名称复用模型按 (vendor, vendor_model_id) 复用
已存在则更新定价不重复创建
已存在则更新不重复创建
规格来源2026-09-05 防编造改造
use_last_extract=true推荐 取本会话 extract 锚定的规格
overrides 白名单覆盖vendor_name 等少量标量
否则用 params.specLLM 手传历史路径结构不符时报错列出期望键
"""
err = await _require_owner(sor, ctx)
if err:
return err
try:
spec = json.loads(params.get("spec") or "{}")
except Exception:
return "spec 不是合法 JSON"
use_last = str(params.get("use_last_extract") or "").lower() in ("true", "1", "yes")
overrides = params.get("overrides") or {}
if isinstance(overrides, str):
try:
overrides = json.loads(overrides)
except Exception:
overrides = {}
spec = None
if use_last:
spec = _spec_load(ctx)
if spec is None:
return ("本会话没有锚定的提取规格——先调 extract_llm_api_spec成功后规格自动"
"锚定本会话),再传 {\"use_last_extract\": true}。"
"注意会话级隔离:其他会话提取的规格本会话不可见,需在本会话重新提取。")
spec = _spec_overlay(spec, overrides)
else:
try:
spec = json.loads(params.get("spec") or "{}")
except Exception:
return "spec 不是合法 JSON"
# 结构校验修B报错给出路不让模型盲改重试
if not isinstance(spec, dict) or "vendor_name" not in spec or "models" not in spec:
got = sorted(spec.keys())[:12] if isinstance(spec, dict) else "(非对象)"
return ("spec 结构不符——不要自己编写/改写规格结构。期望顶层键:"
"vendor_name / base_url / endpoints / protocol / chat_path / models / "
"pricing / doc_url / doc_notesextract_llm_api_spec 的原样输出)。"
"你传入的顶层键:%s。正确做法:调 extract_llm_api_spec 后传 "
"{\"use_last_extract\": true}(规格已锚定本会话,禁止复制转述),"
"需改动加 overrides仅 vendor_name/base_url/protocol/doc_url/doc_notes"
% got)
vendor_name = (spec.get("vendor_name") or "").strip()
if not vendor_name:
return "spec 缺少 vendor_name"
return "spec 缺少 vendor_name(可用 overrides.vendor_name 指定)"
endpoints = spec.get("endpoints") or []
if not endpoints and spec.get("base_url"):
endpoints = [{"base_url": spec["base_url"], "region": "domestic", "timeout": 60}]
@ -657,14 +799,28 @@ async def _h_apply_model_pricing(sor, params, ctx):
幂等键模型已有 ppid 且方案描述含同一出处 URL 更新时序拉链
否则新建方案价格只来自文档原文提取层强制 doc_quote
规格来源2026-09-05 防编造改造use_last_extract=true 取本会话
extract 锚定规格 pricing/doc_url禁止 LLM 复制转述大 JSON
"""
err = await _require_owner(sor, ctx)
if err:
return err
try:
spec = json.loads(params.get("spec") or "{}")
except Exception:
return "spec 不是合法 JSON"
use_last = str(params.get("use_last_extract") or "").lower() in ("true", "1", "yes")
if use_last:
spec = _spec_load(ctx)
if spec is None:
return ("本会话没有锚定的提取规格——先调 extract_llm_api_spec成功后规格自动"
"锚定本会话,含 pricing 与 doc_url再传 {\"use_last_extract\": true}。"
"会话级隔离:其他会话的规格本会话不可见。")
else:
try:
spec = json.loads(params.get("spec") or "{}")
except Exception:
return "spec 不是合法 JSON"
if not isinstance(spec, dict) or "pricing" not in spec:
return ("spec 结构不符——不要自己编写规格。正确做法extract_llm_api_spec 后传 "
"{\"use_last_extract\": true}pricing 与 doc_url 已锚定本会话)。")
pricing = spec.get("pricing") or {}
items = pricing.get("items") or []
if not items:
@ -996,15 +1152,22 @@ PLATFORM_TOOLS = [
),
ToolDefinition(
name="apply_llm_config",
description="按提取的规格写入模型治理配置:供应商/端点/适配模板/模型。幂等——已有模型只更新。定价不在此工具(用 apply_model_pricing。仅owner组织角色可用。",
parameters={"spec": "extract_llm_api_spec 返回的 JSON 规格"},
description="按提取的规格写入模型治理配置:供应商/端点/适配模板/模型。幂等。推荐 use_last_extract=true 取本会话锚定规格(禁止把规格复制进 spec——复制即编造需改动加 overrides仅 vendor_name/base_url/protocol/doc_url/doc_notes。仅owner组织角色可用。",
parameters={
"use_last_extract": "true=用本会话 extract_llm_api_spec 锚定的规格(推荐,会话级隔离)",
"overrides": "覆盖项 JSON白名单vendor_name/base_url/protocol/doc_url/doc_notes如用户要求改供应商名",
"spec": "仅无锚定时的兜底extract 返回的 JSON 规格原文",
},
category="platform",
requires_confirmation=True,
),
ToolDefinition(
name="apply_model_pricing",
description="定价自动导入:按提取规格的 pricing 建定价方案pricing_program+时序YAML并挂模型 ppid。幂等已有 ppid 拉链更新时序)。一个定价方案只服务一个模型;定价相同的模型共享同一 ppid。价格必须来自文档原文每条带 doc_quote 否则拒绝。仅owner组织角色可用。",
parameters={"spec": "extract_llm_api_spec 返回的 JSON 规格(含 pricing 与 doc_url"},
description="定价自动导入建定价方案pricing_program+时序YAML并挂模型 ppid。幂等内容无变化跳过同日原地更新历史行才拉链——时序表始终只一条有效行。一个定价方案只服务一个模型定价相同的模型共享同一 ppid。推荐 use_last_extract=true 取本会话锚定规格(含 pricing/doc_url禁止复制转述。仅owner组织角色可用。",
parameters={
"use_last_extract": "true=用本会话锚定规格的 pricing+doc_url推荐",
"spec": "仅无锚定时的兜底extract 返回的 JSON 规格原文",
},
category="platform",
requires_confirmation=True,
),
@ -1043,12 +1206,14 @@ PLATFORM_PROMPT = """
## 模型自动配置全链工作流(用户给文档 URL 要求配置模型时,按序独立完成)
1. ** `load_skill` 加载 `model-auto-config`**含媒体转换铁律/能力分类先行/定价新模式严格执行
2. `fetch_model_doc` 抓取官方文档返回文本末尾带 [出处URL]
3. `extract_llm_api_spec` 通读文档提取规格端点/协议/能力/sync_mode/async_steps
以及 **pricing每条价格带 doc_quote 文档原文 doc_url照抄出处行**
3. `extract_llm_api_spec` 通读文档提取规格**成功后规格自动锚定到本会话缓存**
会话级隔离返回里有 __anchored__ 提示
4. 把提取摘要给用户确认端点/能力/定价原文/出处确认后
5. `apply_llm_config` 落库供应商/适配模板/模型模型描述自动带 [出处:URL]
6. `apply_model_pricing` 建定价方案并挂 ppid定价描述带出处URL+文档原文
一个方案只服务一个模型 model 维度无 filters维度平铺幂等拉链更新
5. `apply_llm_config` **{"use_last_extract": true}**用户要求改名等只加
overrides 白名单覆盖项 {"overrides": {"vendor_name": "阿里云百炼"}}
**绝对禁止把规格 JSON 复制/转述进 spec 参数转述必编造结构历史教训
三轮全编造 vendor{}/api_profile{} 等不存在的字段**
6. `apply_model_pricing` 同样传 **{"use_last_extract": true}**定价+出处已锚定
7. `test_model_call` 真实调用测试前置账号已配 api_key没配则如实告知用户去补
不要伪造结果生成类模型传 paramsimage_file/resolution/duration
8. `check_model_accounting` 检查记账传上一步的 task_ref
@ -1057,6 +1222,12 @@ PLATFORM_PROMPT = """
- failed reason/hint 处置未映射产品告知用户在产品管理导入产线模型
9. 汇报配置/定价/测试/记账四段结果 + 出处 URL + 遗留事项如模板需人工核对项
## 工具报错自救规则
- spec 结构不符/缺少 xxx **不要改写结构重试**改用 use_last_extract=true
本会话没锚定就重新走 fetchextract锚定是会话级的跨会话不可见
- 能力分类未登记 如实转告用户需先加能力分类这是人工评审门不要绕过
- media_audit/runtime_note/需人工核对警告 原样转告用户不假装完成
## 硬规则
- **定价只允许来自文档原文**doc_quote 强制禁止编造/换算/推导
- **出处必留**模型注册表描述字段与定价方案描述字段都必须保存文档 URL