Files
AgentLab/backend/app/engine/nodes/llm.py
T
ThearchyHelios 3a1e4aa816 模型报错说人话,并堵住 Copilot 猜 model id
用户拿到的是这么一段:

  OpenAIAuthenticationError: Error code: 401 - {'error': {'code':
  'invalid_model', 'message': 'The model does not exist or you do not have
  access to it.'}}
  流式失败,回退非流式:(同一段,再来一遍)

401 + Authentication 看着像 key 过期,实际上 key 好好的——是 model id 在这个
端点上不存在。照着原文去换 key 是白费功夫。而且整条轨迹里**没有任何地方**
记着试的是哪个模型:llm.start 只带了 message_count。

三处:

1. llm.start 带上 model。调用失败时至少知道试的是什么。
2. explain_model_error():invalid_model 译成"模型「x」在这个供应商上不存在…
   去设置→供应商改模型列表";额度耗尽单独认;不认识的错误照抄原文(猜错了
   比不猜更糟)。流式回退再失败时不再把同一段原文吐两遍,而是抛一条
   NodeError。结构化输出那条路径也走同一个翻译——模型不存在不叫"结构化
   输出失败"。
3. Copilot 提示里补上"model 字段不要填"。它原先只被告知 llm 节点有个 model
   字段,却从不知道有哪些 model id,于是按供应商名字猜(看到 DeepSeek 就写
   deepseek-chat)——猜出来的那个正好不存在。这是这次报错的真正来源,不修
   的话每次生成都会再踩一遍。

顺带把那个供应商的模型列表改对了:列着的 deepseek-chat / deepseek-reasoner
两个都返回 invalid_model,而真正能用的 deepseek-v4-pro 反倒不在列表里(它是
default_model,所以"跟随默认"的节点一直是好的,手动选模型的节点必挂)。
逐个实测后换成 deepseek-v4-pro / deepseek-v3.2 / ernie-4.5-turbo-128k。
已确认没有已保存的工作流节点写死了那两个失效 id。
2026-09-17 18:41:14 +08:00

444 lines
18 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.
from __future__ import annotations
import json
import time
from typing import Any
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import BaseTool
from langgraph.func import task
from langgraph.types import interrupt
from sqlalchemy import select
from app.core.config import settings
from app.core.events import EventType
from app.db.base import SessionLocal
from app.db.models import Skill
from app.engine.context import NodeContext, NodeError
from app.engine.state import GraphState, message_text, template_context, thinking_text
from app.providers import catalog
from app.providers.factory import ModelSpec, ProviderNotConfigured, get_chat_model
from app.tools.registry import ToolContext, build_tools, get_spec
# --------------------------------------------------------------------------
# 公共部分
# --------------------------------------------------------------------------
def _model_spec(ctx: NodeContext) -> ModelSpec:
return ModelSpec(
provider=ctx.cfg("provider"),
model=ctx.cfg("model"),
temperature=ctx.cfg("temperature"),
max_tokens=ctx.cfg("max_tokens"),
thinking=ctx.cfg("thinking"),
effort=ctx.cfg("effort"),
)
async def _load_skills(names: list[str]) -> str:
"""把挂载的 Skill 拼成一段 system 指令。
Skill 把"怎么做事"从图结构里抽出来单独管理 —— 改方法论不用动编排。
"""
if not names:
return ""
async with SessionLocal() as session:
rows = list(
(
await session.execute(
select(Skill).where(Skill.name.in_(names), Skill.enabled.is_(True))
)
).scalars()
)
if not rows:
return ""
blocks: list[str] = []
for skill in rows:
block = f"## {skill.name}\n{skill.instructions.strip()}"
if skill.examples:
examples = "\n".join(
f"- 输入:{e.get('input', '')}\n 输出:{e.get('output', '')}"
for e in skill.examples
if isinstance(e, dict)
)
if examples:
block += f"\n\n示例:\n{examples}"
blocks.append(block)
return "# 适用的方法论\n\n" + "\n\n".join(blocks)
async def _build_messages(state: GraphState, ctx: NodeContext) -> list[BaseMessage]:
tctx = template_context(state)
messages: list[BaseMessage] = []
system_parts: list[str] = []
system = ctx.render_str(ctx.cfg("system", ""), state)
if system:
system_parts.append(system)
skill_text = await _load_skills(ctx.cfg("skills", []) or [])
if skill_text:
system_parts.append(skill_text)
if system_parts:
messages.append(SystemMessage(content="\n\n".join(system_parts)))
# 是否把之前节点的对话历史带上
if ctx.cfg("use_history", False):
messages.extend(state.get("messages") or [])
prompt = ctx.render_str(ctx.cfg("prompt", ""), state)
if not prompt and not ctx.cfg("use_history", False):
prompt = str(tctx.get("last_message") or json.dumps(tctx.get("input"), ensure_ascii=False))
if prompt:
messages.append(HumanMessage(content=prompt))
if not messages:
raise NodeError(ctx.node.id, "没有可发送的内容:system 和 prompt 都是空的")
return messages
def _usage_of(message: BaseMessage, model_id: str) -> dict[str, Any]:
meta = getattr(message, "usage_metadata", None) or {}
inp = int(meta.get("input_tokens") or 0)
out = int(meta.get("output_tokens") or 0)
return {
"input_tokens": inp,
"output_tokens": out,
"total_tokens": inp + out,
"cost_usd": round(catalog.estimate_cost(model_id, inp, out), 6),
"calls": 1,
}
def explain_model_error(exc: Exception, model_id: str) -> str:
"""把供应商的原始报错翻成能照着做的一句话。
最典型的是 `401 invalid_model`:网关用"认证失败"的状态码报"模型不存在",
原文长得像 key 过期,实际上 key 好好的、只是这个 model id 在这个端点上
不存在(供应商配置里列了几个它根本不提供的模型,就会这样)。照着原文去
换 key 是白费功夫,所以这里必须把话说对。
"""
text = str(exc)
if "invalid_model" in text or "model does not exist" in text.lower():
return (
f"模型「{model_id}」在这个供应商上不存在,或者当前 key 没有它的权限。"
f"去「设置 → 供应商」把模型列表改成它真正提供的 id,"
f"或者在节点上换一个模型。"
)
if "insufficient" in text.lower() or "quota" in text.lower():
return f"模型「{model_id}」的额度用完了:{text[:200]}"
return f"调用模型「{model_id}」失败:{type(exc).__name__}: {text[:300]}"
async def _invoke_streaming(
model: Any, messages: list[BaseMessage], ctx: NodeContext, model_id: str = ""
) -> AIMessage:
"""流式调用并把 token 实时推给前端。
失败时回退到非流式 —— 有些兼容网关不支持 SSE,不该因此让整个节点挂掉。
但回退也失败的话,两次都是同一个原因,不能只把原始异常往上抛:用户看到的
会是两段一模一样的供应商报错,而里面既没有模型名也没有下一步该干什么。
"""
# 模型 id 要跟着 llm.start 走。以前这条事件只有 message_count,于是调用失败时
# 整条轨迹里**没有任何地方**记着试的是哪个模型——排查只能靠翻配置猜。
ctx.emit(EventType.LLM_START, model=model_id or None, message_count=len(messages))
chunks: list[Any] = []
response: AIMessage | None = None
try:
async for chunk in model.astream(messages):
chunks.append(chunk)
text = message_text(chunk)
if text:
ctx.emit(EventType.LLM_TOKEN, delta=text)
reasoning = thinking_text(chunk)
if reasoning:
# 增量只服务画布的实时滚动,不进轨迹(runner 标记为 ephemeral)
ctx.emit(EventType.LLM_THINKING_DELTA, delta=reasoning)
except NotImplementedError:
response = await model.ainvoke(messages)
except Exception as e: # noqa: BLE001
ctx.emit(EventType.LOG, level="warn",
message=f"流式失败,回退非流式:{explain_model_error(e, model_id)}")
try:
response = await model.ainvoke(messages)
except Exception as retry_error: # noqa: BLE001
raise NodeError(ctx.node.id, explain_model_error(retry_error, model_id)) from retry_error
if response is None:
if not chunks:
response = await model.ainvoke(messages)
else:
merged = chunks[0]
for chunk in chunks[1:]:
merged = merged + chunk
response = merged
# 一次思考在轨迹里只占一条事件:调用收尾时落完整内容,
# 刷新页面回放时也靠它一次性恢复,而不是重放几十条增量
reasoning = thinking_text(response)
if reasoning:
ctx.emit(
EventType.LLM_THINKING,
text=reasoning[:6000],
chars=len(reasoning),
truncated=len(reasoning) > 6000,
)
return response
# --------------------------------------------------------------------------
# LLM 节点:单次调用
# --------------------------------------------------------------------------
async def run_llm(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
spec = _model_spec(ctx)
async with SessionLocal() as session:
try:
model, model_id = await get_chat_model(session, spec)
except ProviderNotConfigured as e:
raise NodeError(ctx.node.id, str(e)) from e
messages = await _build_messages(state, ctx)
schema = ctx.cfg("output_schema")
started = time.perf_counter()
if schema:
# 要结构化结果就走原生 structured output,这条路径拿不到 token 流
ctx.emit(EventType.LLM_START, model=model_id, structured=True,
message_count=len(messages))
try:
# langchain 靠 "title" 识别裸 JSON Schema dict,缺了会抛 Unsupported function
if isinstance(schema, dict) and "title" not in schema:
schema = {"title": "output", **schema}
structured = model.with_structured_output(schema)
value = await structured.ainvoke(messages)
except Exception as e: # noqa: BLE001
# 模型压根不存在也会走到这里,那不是"结构化输出失败"
if "invalid_model" in str(e) or "model does not exist" in str(e).lower():
raise NodeError(ctx.node.id, explain_model_error(e, model_id)) from e
raise NodeError(ctx.node.id, f"结构化输出失败:{type(e).__name__}: {e}") from e
payload = value if isinstance(value, (dict, list)) else getattr(value, "model_dump", lambda: value)()
output = {"data": payload, "text": json.dumps(payload, ensure_ascii=False, indent=2)}
response: BaseMessage = AIMessage(content=output["text"])
else:
response = await _invoke_streaming(model, messages, ctx, model_id)
text = message_text(response)
reasoning = thinking_text(response)
if not text and reasoning:
# 思考型模型把额度全花在推理上了,正文是空的 —— 说清楚而不是抛个空结果给下游
ctx.emit(EventType.LOG, level="warn",
message="模型只输出了思考内容,正文为空。把 max_tokens 调大,或把 thinking 设为 off。")
output = {"text": text, "thinking": reasoning}
usage = _usage_of(response, model_id)
elapsed = int((time.perf_counter() - started) * 1000)
ctx.emit(
EventType.LLM_END,
model=model_id,
duration_ms=elapsed,
**usage,
)
result = {**output, "model": model_id, "duration_ms": elapsed}
updates: dict[str, Any] = {
"nodes": {ctx.node.id: result},
"usage": usage,
}
if ctx.cfg("emit_message", True):
updates["messages"] = [response]
var_name = ctx.cfg("assign_to", "")
if var_name:
updates["vars"] = {var_name: output.get("data", output.get("text"))}
return updates
# --------------------------------------------------------------------------
# Agent 节点:带工具循环
# --------------------------------------------------------------------------
async def _resolve_tools(ctx: NodeContext, state: GraphState) -> list[BaseTool]:
names = ctx.cfg("tools", []) or []
tool_ctx = ToolContext(
run_id=ctx.run.run_id,
node_id=ctx.node.id,
sandbox_session=ctx.run.thread_id,
memory_scope=ctx.cfg("memory_scope") or ctx.run.memory_scope,
collection=ctx.cfg("collection") or ctx.run.collection,
)
async with SessionLocal() as session:
return await build_tools(names, tool_ctx, session=session)
def _needs_approval(ctx: NodeContext, tool_name: str) -> bool:
mode = ctx.cfg("approval", "dangerous") # never | dangerous | always
if mode == "always":
return True
if mode == "never":
return False
spec = get_spec(tool_name)
return bool(spec and spec.dangerous)
async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""ReAct 循环:模型想调工具就调,拿到结果继续想,直到给出最终答案。
这里没有用 prebuilt 的 create_react_agent,因为需要在循环内部做三件它不做的事:
每步工具调用都往画布推事件、对危险工具逐个弹人工审批、以及步数护栏。
"""
model_spec = _model_spec(ctx)
async with SessionLocal() as session:
try:
base_model, model_id = await get_chat_model(session, model_spec)
except ProviderNotConfigured as e:
raise NodeError(ctx.node.id, str(e)) from e
tools = await _resolve_tools(ctx, state)
tool_map = {t.name: t for t in tools}
model = base_model.bind_tools(tools) if tools else base_model
messages = await _build_messages(state, ctx)
max_steps = min(int(ctx.cfg("max_steps", 8) or 8), settings.max_agent_steps)
total_usage: dict[str, Any] = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0, "calls": 0}
transcript: list[dict[str, Any]] = []
# @task 的返回值会进 checkpoint:人工审批导致节点重放时,
# 之前已经完成的模型调用和工具执行不会重跑,也就不会重复计费。
@task
async def llm_step(step: int, payload: list[BaseMessage]) -> BaseMessage:
return await _invoke_streaming(model, payload, ctx, model_id)
@task
async def tool_step(step: int, name: str, args: dict[str, Any]) -> str:
tool = tool_map[name]
result = await tool.ainvoke(args)
return result if isinstance(result, str) else json.dumps(result, ensure_ascii=False, default=str)
final_text = ""
for step in range(max_steps):
response = await llm_step(step, messages)
messages.append(response)
usage = _usage_of(response, model_id)
for key in ("input_tokens", "output_tokens", "calls"):
total_usage[key] += usage[key]
total_usage["cost_usd"] = round(total_usage["cost_usd"] + usage["cost_usd"], 6)
tool_calls = getattr(response, "tool_calls", None) or []
if not tool_calls:
final_text = message_text(response)
break
for call in tool_calls:
name = call.get("name", "")
args = call.get("args", {}) or {}
call_id = call.get("id") or f"{ctx.node.id}-{step}-{name}"
if name not in tool_map:
messages.append(
ToolMessage(content=f"错误:没有名为 {name} 的工具", tool_call_id=call_id)
)
continue
if _needs_approval(ctx, name):
ctx.emit(
EventType.HUMAN_REQUESTED,
mode="approve",
tool=name,
args=args,
title=f"Agent 想调用工具 {name}",
)
decision = interrupt(
{
"kind": "tool_approval",
"node_id": ctx.node.id,
"tool": name,
"args": args,
"title": f"是否允许调用 {name}?",
}
)
approved, note, override = _parse_decision(decision)
ctx.emit(EventType.HUMAN_RESOLVED, tool=name, approved=approved, note=note)
if not approved:
messages.append(
ToolMessage(
content=f"用户拒绝了这次调用。原因:{note or '未说明'}。请换一种方式。",
tool_call_id=call_id,
)
)
transcript.append({"tool": name, "args": args, "denied": True, "note": note})
continue
if isinstance(override, dict) and override:
args = override
ctx.emit(EventType.TOOL_START, tool=name, args=args, call_id=call_id)
started = time.perf_counter()
try:
content = await tool_step(step, name, args)
ok = True
except Exception as e: # noqa: BLE001 - 工具失败要喂回模型,让它自己纠错
content = f"工具执行失败:{type(e).__name__}: {e}"
ok = False
elapsed = int((time.perf_counter() - started) * 1000)
snapshot_id: str | None = None
if ok:
from app.core.artifact_store import put_json
try:
snapshot_id = await put_json(
{"tool": name, "args": args, "result": content},
kind="tool_snapshot", run_id=ctx.run.run_id, node_id=ctx.node.id,
)
except Exception: # noqa: BLE001
snapshot_id = None
ctx.emit(
EventType.TOOL_END if ok else EventType.TOOL_ERROR,
tool=name,
call_id=call_id,
duration_ms=elapsed,
preview=content[:2000],
artifact=snapshot_id,
)
transcript.append(
{"tool": name, "args": args, "ok": ok, "duration_ms": elapsed, "result": content[:4000]}
)
messages.append(ToolMessage(content=content, tool_call_id=call_id))
else:
final_text = message_text(messages[-1]) if messages else ""
ctx.emit(
EventType.LOG,
level="warn",
message=f"agent 达到最大步数 {max_steps},提前收尾",
)
total_usage["total_tokens"] = total_usage["input_tokens"] + total_usage["output_tokens"]
result = {
"text": final_text,
"steps": len(transcript),
"tool_calls": transcript,
"model": model_id,
}
updates: dict[str, Any] = {
"nodes": {ctx.node.id: result},
"usage": total_usage,
}
if ctx.cfg("emit_message", True):
updates["messages"] = [AIMessage(content=final_text or "(空)")]
var_name = ctx.cfg("assign_to", "")
if var_name:
updates["vars"] = {var_name: final_text}
return updates
def _parse_decision(decision: Any) -> tuple[bool, str, dict[str, Any] | None]:
"""人工审批的返回值兼容几种写法:布尔、字符串、或带备注的结构体。"""
if isinstance(decision, bool):
return decision, "", None
if isinstance(decision, str):
return decision.lower() in ("yes", "y", "true", "approve", "ok", "同意"), decision, None
if isinstance(decision, dict):
approved = decision.get("approved")
if approved is None:
approved = decision.get("decision") in ("approve", "yes", True)
return bool(approved), str(decision.get("note", "")), decision.get("args")
return bool(decision), "", None