mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:41:57 +00:00
模型报错说人话,并堵住 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。
This commit is contained in:
@@ -124,6 +124,12 @@ NODE_REFERENCE = """\
|
||||
- transform:数据整形。config: {mode: expression|template|json, expression/template, assign_to}
|
||||
- subgraph:嵌套另一个工作流。config: {workflow_id, input}
|
||||
|
||||
模型字段(llm / agent / supervisor 的 config.model):
|
||||
- **不要填**。留空表示跟随当前供应商的默认模型,这几乎总是对的。
|
||||
- 你不知道这套部署里有哪些 model id——凭供应商名字猜(比如看到 DeepSeek 就写
|
||||
"deepseek-chat")会得到 401 invalid_model,整个节点跑不起来。
|
||||
- 只有用户在需求里明确点名了某个模型,才把他说的那个字符串原样填进去。
|
||||
|
||||
模板语法(在任意字符串里用):
|
||||
- {{ input.字段名 }} 入口输入
|
||||
- {{ vars.变量名 }} 某节点 assign_to 写入的变量
|
||||
|
||||
@@ -109,12 +109,38 @@ def _usage_of(message: BaseMessage, model_id: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def _invoke_streaming(model: Any, messages: list[BaseMessage], ctx: NodeContext) -> AIMessage:
|
||||
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,不该因此让整个节点挂掉。
|
||||
但回退也失败的话,两次都是同一个原因,不能只把原始异常往上抛:用户看到的
|
||||
会是两段一模一样的供应商报错,而里面既没有模型名也没有下一步该干什么。
|
||||
"""
|
||||
ctx.emit(EventType.LLM_START, message_count=len(messages))
|
||||
# 模型 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:
|
||||
@@ -130,8 +156,12 @@ async def _invoke_streaming(model: Any, messages: list[BaseMessage], ctx: NodeCo
|
||||
except NotImplementedError:
|
||||
response = await model.ainvoke(messages)
|
||||
except Exception as e: # noqa: BLE001
|
||||
ctx.emit(EventType.LOG, level="warn", message=f"流式失败,回退非流式:{e}")
|
||||
response = await model.ainvoke(messages)
|
||||
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:
|
||||
@@ -174,7 +204,8 @@ async def run_llm(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
|
||||
if schema:
|
||||
# 要结构化结果就走原生 structured output,这条路径拿不到 token 流
|
||||
ctx.emit(EventType.LLM_START, structured=True, message_count=len(messages))
|
||||
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:
|
||||
@@ -182,12 +213,15 @@ async def run_llm(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
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)
|
||||
response = await _invoke_streaming(model, messages, ctx, model_id)
|
||||
text = message_text(response)
|
||||
reasoning = thinking_text(response)
|
||||
if not text and reasoning:
|
||||
@@ -272,7 +306,7 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
# 之前已经完成的模型调用和工具执行不会重跑,也就不会重复计费。
|
||||
@task
|
||||
async def llm_step(step: int, payload: list[BaseMessage]) -> BaseMessage:
|
||||
return await _invoke_streaming(model, payload, ctx)
|
||||
return await _invoke_streaming(model, payload, ctx, model_id)
|
||||
|
||||
@task
|
||||
async def tool_step(step: int, name: str, args: dict[str, Any]) -> str:
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""模型调用失败时说的话。
|
||||
|
||||
真实случай:供应商的模型列表里列了两个它根本不提供的 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 好好的。照着原文去换 key 是
|
||||
白费功夫。而且整条轨迹里没有任何地方记着试的是哪个模型——因为 llm.start 只带
|
||||
message_count。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.engine.nodes.llm import explain_model_error
|
||||
|
||||
|
||||
class _FakeAuthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def test_invalid_model_says_model_not_key() -> None:
|
||||
exc = _FakeAuthError(
|
||||
"Error code: 401 - {'error': {'code': 'invalid_model', 'message': "
|
||||
"'The model does not exist or you do not have access to it.', "
|
||||
"'type': 'invalid_request_error'}}"
|
||||
)
|
||||
msg = explain_model_error(exc, "deepseek-chat")
|
||||
|
||||
assert "deepseek-chat" in msg, "必须说清是哪个模型,否则没法排查"
|
||||
assert "不存在" in msg
|
||||
assert "设置" in msg and "供应商" in msg, "要给出下一步去哪儿改"
|
||||
# 别把用户往换 key 的方向带
|
||||
assert "401" not in msg
|
||||
assert "Authentication" not in msg
|
||||
|
||||
|
||||
def test_quota_is_not_confused_with_missing_model() -> None:
|
||||
exc = _FakeAuthError("Error code: 429 - insufficient_quota: balance is 0")
|
||||
msg = explain_model_error(exc, "gpt-4o")
|
||||
assert "额度" in msg and "gpt-4o" in msg
|
||||
assert "不存在" not in msg
|
||||
|
||||
|
||||
def test_unknown_error_keeps_the_original_text() -> None:
|
||||
"""不认识的错误照抄原文——猜错了比不猜更糟。"""
|
||||
exc = _FakeAuthError("connection reset by peer")
|
||||
msg = explain_model_error(exc, "some-model")
|
||||
assert "some-model" in msg
|
||||
assert "connection reset by peer" in msg
|
||||
|
||||
|
||||
def test_long_errors_are_trimmed() -> None:
|
||||
exc = _FakeAuthError("x" * 5000)
|
||||
msg = explain_model_error(exc, "m")
|
||||
assert len(msg) < 400
|
||||
Reference in New Issue
Block a user