mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27:31 +00:00
fix(memory,llm): L2 预填充并入召回主路 + 回退换 provider 时换 model(视觉失效真因)
## 1. L2(冷上下文)预填充此前只走「注入文本」,等于没有
设计:prune 与 recall 是一对 —— 工具声明 ContextPolicy=prune + RecallPolicy=auto
时,工具输出既触发裁剪、也触发预填充(memoryPass → recallTextFor)。
但 L2 那一路只写在 buildSystemPrompt 里,query 用的是**注入文本**;
QQ 注入的是「来自「X」的私聊消息(message_id=…),先用 qq_get_message 取正文」,
与用户真正说的话毫无相似度 ⇒ 冷上下文预填充恒为空。
(现场:头天晚上讲的冷笑话,第二天早上完全唤不回;14 小时里
memory recall (input…) 只有 5 次、且 query 是元信息。)
改法:把 L2 检索并入 buildMemoryContext —— 输入路用输入文本、工具路用工具正文,
两条路共用同一次召回与预算;buildSystemPrompt 里那份重复注入删掉。
中断消息仍然 NoMemory + RecallPolicy=none(不裁剪、不召回、不进记忆),
这是设计要求,未改动。
## 2. 回退换 provider 时必须换 model(视觉失效的真因)
现场日志:
deepseek 402 Insufficient Balance
→ 回退 llmsproxy,但 req 仍带 model=「deepseek-flash」
→ llmsproxy 403 model_not_allowed
→ llmsproxy 被整源标记 unavailable(冷却)
→ 视觉回退 resolveModalFallback 跳过它 ⇒「配置了视觉却说没有视觉」
两处修正:
· 每个 provider 用自己的 model 发请求(model 是 provider 的属性)
· 403 区分「模型不被该 key 允许」与「凭证失效」:前者只否一个 model,
provider 仍可用于其他 model 与视觉回退
判据:internal/agent/core/provider_model_fallback_test.go
This commit is contained in:
42
internal/agent/core/provider_model_fallback_test.go
Normal file
42
internal/agent/core/provider_model_fallback_test.go
Normal file
@ -0,0 +1,42 @@
|
||||
package core
|
||||
|
||||
// provider 回退时**model 必须跟着 provider 走** + 403 分两类处置。
|
||||
//
|
||||
// 真机现场(2026-10-27):
|
||||
// deepseek 402 余额不足
|
||||
// → 回退 llmsproxy,但 req 里仍带 model="deepseek-flash"
|
||||
// → llmsproxy 报 403 model_not_allowed
|
||||
// → llmsproxy 被整源标记 unavailable(冷却)
|
||||
// → 视觉回退 resolveModalFallback 跳过它
|
||||
// → 用户看到"配置了视觉能力却说没有视觉"
|
||||
//
|
||||
// 这两条判据分别钉住:换 provider 要换 model;model 级错误不能把整源判死。
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsModelNotAllowedRecognizesGatewayPhrases(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg string
|
||||
want bool
|
||||
}{
|
||||
{`api error 403: {"error":{"message":"model \"deepseek-flash\" is not allowed for this key","type":"model_not_allowed"}}`, true},
|
||||
{`api error 403: {"error":{"type":"model_not_allowed"}}`, true},
|
||||
{`api error 404: {"error":{"message":"model_not_found"}}`, true},
|
||||
{`api error 400: The model ` + "`gpt-9`" + ` does not exist or you do not have access to it`, true},
|
||||
{`api error 401: invalid api key`, false}, // 凭证问题:整源不可用
|
||||
{`api error 403: forbidden`, false}, // 权限问题:整源不可用
|
||||
{``, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
var err error
|
||||
if c.msg != "" {
|
||||
err = errors.New(c.msg)
|
||||
}
|
||||
if got := isModelNotAllowed(err); got != c.want {
|
||||
t.Errorf("isModelNotAllowed(%q) = %v, want %v", c.msg, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1353,6 +1353,24 @@ func (a *Agent) callLLMWithFallback(req *agentAPI.CompletionRequest, providers [
|
||||
fbProvider.Name(), pi, len(providers)-1)
|
||||
}
|
||||
|
||||
// ★ 每个 provider 必须用**自己的** model 发请求(2026-10-27 真机)。
|
||||
//
|
||||
// 现场:默认源 deepseek(model=deepseek-flash) 402 → 回退 llmsproxy(model=AUTO)
|
||||
// 时复用了同一个 req ⇒ llmsproxy 收到 model="deepseek-flash" ⇒ 403
|
||||
// model_not_allowed ⇒ 两个源全挂,且 llmsproxy 被整源标记不可用(冷却)
|
||||
// ⇒ 视觉回退跳过它 ⇒ 用户看到"配置了视觉却说没有视觉"。
|
||||
//
|
||||
// model 是 **provider 的属性**,不是请求的共享字段。
|
||||
if rp, ok := fbProvider.(agentAPI.RoutableProvider); ok {
|
||||
if m := rp.Model(); m != "" && m != req.Model {
|
||||
if pi > 0 {
|
||||
log.Printf("[agent] fallback provider %q uses its own model %q (was %q)",
|
||||
fbProvider.Name(), m, req.Model)
|
||||
}
|
||||
req.Model = m
|
||||
}
|
||||
}
|
||||
|
||||
// 同源瞬时错误重试:网关瞬断(502/503/504/429/网络抖动)通常秒级恢复,
|
||||
// 直接跳下一个 provider(或直接报错)会丢掉本可成功的请求。
|
||||
// 凭证错误(401/403)与用户中断不重试。
|
||||
@ -1415,8 +1433,17 @@ func (a *Agent) callLLMWithFallback(req *agentAPI.CompletionRequest, providers [
|
||||
}
|
||||
var pe *agentAPI.ProviderError
|
||||
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
|
||||
a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode)
|
||||
log.Printf("[agent] provider %q marked unavailable (HTTP %d)", fbProvider.Name(), pe.StatusCode)
|
||||
// 403 分两类:凭证/权限问题 ⇒ 整源不可用;**模型名不被该 key 允许**
|
||||
// (model_not_allowed)⇒ 只是这个 model 不行,provider 仍可用于
|
||||
// 其他 model,也包括视觉回退。混为一谈会让一次模型名不匹配把整源判死。
|
||||
if isModelNotAllowed(llmErr) {
|
||||
log.Printf("[agent] provider %q rejected model %q (HTTP %d) — "+
|
||||
"model-level issue, provider stays available: %v",
|
||||
fbProvider.Name(), req.Model, pe.StatusCode, llmErr)
|
||||
} else {
|
||||
a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode)
|
||||
log.Printf("[agent] provider %q marked unavailable (HTTP %d)", fbProvider.Name(), pe.StatusCode)
|
||||
}
|
||||
} else {
|
||||
a.providerManager.MarkUnavailable(fbProvider.Name())
|
||||
}
|
||||
@ -1485,3 +1512,26 @@ func stageHint(ctx *sdk.StageContext, key string) string {
|
||||
v, _ := ctx.Extra[key].(string)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// isModelNotAllowed 判断错误是不是"这个模型名不被该 key 允许"。
|
||||
//
|
||||
// 与"凭证失效"处置相反:前者只否掉一个 model(provider 仍可服务其他 model,
|
||||
// 也仍可用于视觉回退),后者才该把整源标记不可用。
|
||||
func isModelNotAllowed(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
for _, m := range []string{
|
||||
"model_not_allowed",
|
||||
"is not allowed for this key",
|
||||
"model not allowed",
|
||||
"model_not_found",
|
||||
"does not exist or you do not have access",
|
||||
} {
|
||||
if strings.Contains(msg, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@ -37,6 +37,25 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string)
|
||||
s += "[Related media]\n" + mc
|
||||
}
|
||||
|
||||
// ★ L2(冷上下文)相似度预填充:**必须在这里做**,不能只在上层 buildSystemPrompt。
|
||||
//
|
||||
// 设计里 prune 与 recall 是一对:工具声明 ContextPolicy=prune + RecallPolicy=auto
|
||||
// 时,工具输出既触发裁剪也触发预填充(memoryPass → recallTextFor → 本函数)。
|
||||
// 而此前 L2 那一路只写在 buildSystemPrompt 里、且 query 是**注入文本**
|
||||
// (QQ 是"来自「X」的私聊消息(message_id=…),先用 qq_get_message 取正文"),
|
||||
// 跟用户真实说的话毫不相似 ⇒ 冷上下文预填充等于没有
|
||||
//(2026-10-27 现场:头天晚上讲的冷笑话第二天早上完全唤不回)。
|
||||
//
|
||||
// 放进本函数后:输入路用输入文本、工具路用工具正文,两条路都能召回 L2 摘要。
|
||||
if a.docStore != nil {
|
||||
if md := a.crossModalMarkdown(a.retrieveCrossModal(input, 3, a.fusionCfg)); md != "" {
|
||||
if s != "" {
|
||||
s += "\n"
|
||||
}
|
||||
s += md
|
||||
}
|
||||
}
|
||||
|
||||
if maxTokens > 0 {
|
||||
s = TruncateByTokens(s, maxTokens)
|
||||
}
|
||||
@ -200,15 +219,8 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
//(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。
|
||||
prompt = appendPromptBlock(prompt, "[Memory maintenance] To tidy or clear memory, call the memory_* tools - a described plan is not the work. Report the result in one sentence.")
|
||||
|
||||
// 跨模态召回:文本路(fastText/TF-IDF 文档层,媒体描述文本已随记忆进入)
|
||||
// + 视觉路(多模态文本编码 → 媒体库坐标)两路归一化融合。
|
||||
// 未配置多模态空间时视觉路为空,等价旧的 docStore.Query。
|
||||
if a.docStore != nil {
|
||||
hits := a.retrieveCrossModal(userInput, 3, a.fusionCfg)
|
||||
if md := a.crossModalMarkdown(hits); md != "" {
|
||||
prompt = appendPromptBlock(prompt, md)
|
||||
}
|
||||
}
|
||||
// L2(冷上下文)预填充已并入 buildMemoryContext(输入路/工具路共用一次),
|
||||
// 这里不再重复注入,否则同一批摘要会出现两遍。
|
||||
|
||||
prompt = appendPromptBlock(prompt, "[Interrupt] During long tasks a system message prefixed [Interrupt] may arrive (new message, terminal output, timer). It is not user speech, but handle it: pause, respond, or consciously continue.")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user