feat(asr): 本地语音转写接入为一级路由 + 并行工作流合并提交

按用户指示做**一包提交**,不按工作流拆分。本提交刻意混合了多条并行线:

  · 本地 ASR 接管:audio 成为与 chat/embed/image/video 同等的路由类别
    (IsLocalRoute 单一判据、audio 健康探测、default_audio_route、
    auto 占位、GET /api/ai/routes/audio、回退云端时界面明示「音频已出网」)
  · LLM 调用层:ctx 贯穿、ToolCall/ToolSchema、EmptyCompletionError /
    TransientUpstreamError(按错误类型而非文案判重试)
  · 编排 Agent:general_assistant orchestrate/persistence/spec_driver
  · 联网搜索:internal/search(playwright)
  · 网盘:backend + 前端
  · 前端 UI:导航/路由/工作台若干页
  · 交付文档:DELIVERY.md / AR04 / 部署文档的「无 Python」表述据实改写,
    新增 eai_agentplatform-asr.service、asr.env、clonezilla-cleanup 清 ~/asr-poc

不分拆的原因:dev 早期,粒度不该打断工作节奏。且实测过——这些改动
**在编译上是同一个单元**(llm.go 的 ctx 签名变更牵动 12 个调用点,
chat_message.go 的 ctx 改动又与编排重写同处一个 hunk),拆出来的中间态编不过。
详见 TOP_CODING_RULES.md G14.5 与 bugs_and_errors.md E09。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-26 22:21:39 +08:00
co-authored by Claude Code
parent e73169df50
commit c1af86c934
192 changed files with 18046 additions and 392 deletions
@@ -0,0 +1,438 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"time"
"eai_agentplatform/backend/internal/config"
)
// debugWriter 默认丢弃;当 AI_AGENT_DEBUG=1 时输出到 stdout,便于临时诊断多轮工具调用。
var debugWriter io.Writer = io.Discard
func init() {
if v, ok := os.LookupEnv("AI_AGENT_DEBUG"); ok && v != "" && v != "0" {
debugWriter = os.Stdout
}
}
// Tool 一个可被 LLM 调用的工具。
type Tool interface {
// Schema 返回 OpenAI 兼容的 function schema,用于随请求提交给 LLM。
Schema() ToolSchema
// Execute 执行工具,args 为模型传入的原始 JSON 参数,返回给模型的纯文本结果。
Execute(ctx context.Context, args json.RawMessage) (string, error)
}
// Agent 带工具执行能力的对话代理。
//
// Agent 采用 OpenAI 兼容的 tool_calls 环形调用:
// 1. 携带 tools 调用 LLM
// 2. 若模型返回 tool_calls,逐一执行并把结果以 role=tool 消息回填
// 3. 再次调用 LLM,直到模型返回纯文本(无新 tool_calls)
//
// 相比裸 Client,Agent 让 LLM 能按需自主调用搜索等工具。
type Agent struct {
client *Client
tools map[string]Tool
toolSchemas []ToolSchema
maxRounds int
}
// NewAgent 基于指定 AI 路由创建带工具能力的 Agent。
func NewAgent(route *config.RouteConfig) *Agent {
return &Agent{
client: NewClient(route),
tools: make(map[string]Tool),
maxRounds: 8,
}
}
// RegisterTool 注册一个可供 LLM 调用的工具。
func (a *Agent) RegisterTool(t Tool) {
a.tools[t.Schema().Function.Name] = t
a.toolSchemas = append(a.toolSchemas, t.Schema())
}
// SetMaxRounds 调整最大工具调用轮数,避免死循环。
func (a *Agent) SetMaxRounds(n int) {
if n > 0 {
a.maxRounds = n
}
}
// Run 执行一轮带工具的多轮对话,返回最终文本。
//
// messages 会被克隆后追加历史,不作为参数直接变更,避免污染调用方。
func (a *Agent) Run(ctx context.Context, messages []Message) (string, error) {
content, _, _, err := a.RunWithUsage(ctx, messages)
return content, err
}
// RunWithUsage 与 Run 相同,额外累计多轮 token 消耗,便于审计计费。
//
// 返回最终文本、累计 usage 与最终模型信息。usage 为多轮 tool_calls 的各次
// PromptTokens / CompletionTokens 累加(TotalTokens 仅为参考,不累加)。
func (a *Agent) RunWithUsage(ctx context.Context, messages []Message) (string, ChatResult, *config.RouteConfig, error) {
// 复制消息历史,防止调用方切片被并发修改
var history []Message
history = append(history, messages...)
var usage ChatResult
var toolRounds int // 已执行的工具调用轮数,用于打断 tool_calls 轮转过量/死循环
for round := 0; round < a.maxRounds; round++ {
// 轮次间检查上层上下文(如编排 150s 兜底)是否已取消:
// 若已取消,立即退出,避免已发起的慢调用/死循环续命拖死 wg.Wait()。
if err := ctx.Err(); err != nil {
return "", usage, a.client.Route(), fmt.Errorf("上下文已取消,终止工具调用: %w", err)
}
out, err := a.roundWithEmptyRecovery(ctx, history)
if err != nil {
return "", usage, a.client.Route(), err
}
// 累计 token 消耗(多轮加总)
usage.Usage.PromptTokens += out.Usage.PromptTokens
usage.Usage.CompletionTokens += out.Usage.CompletionTokens
if out.Model != "" {
usage.Model = out.Model
}
// 记录模型回复
history = append(history, Message{Role: "assistant", Content: out.Content, ToolCalls: out.ToolCalls})
// 无工具调用 → 返回最终文本
if len(out.ToolCalls) == 0 {
// 部分 provider(如 LMUAI/DeepSeek 兼容 Anthropic)会把工具调用意图以
//「文本格式」输出(<|| calls> 这类 Anthropic tool_use 转义标签),而不是
// OpenAI 规范的 tool_calls JSON。此时 ToolCalls 为空、Content 便是那串原始标签,
// 若直接返回会把 <|| invoke name="web_search"> 之类的噪声原文展示给用户。
// 识别到这类「工具回显」后,不保留原样:先尝试无工具纯文本收敛;若模型惯性
// 仍输出标签(历史里已存在 assistant 工具标签消息,易诱导),则剥离标签,
// 只把其中可读的自然语言/兜底文案交给用户。
if out.Content != "" && looksLikeToolEcho(out.Content) && len(a.toolSchemas) > 0 {
// 先剥离工具标签提取正文:若模型在本轮已写出正文(只是混入了工具回显标签),
// 直接返回正文,避免接下来的无谓收敛覆盖掉真实产出(worker 尤其如此)。
if s := stripToolEcho(out.Content); s != "" {
return s, usage, a.client.Route(), nil
}
// 纯工具回显、无任何正文:才做一次无工具纯文本收敛;若收敛仍剥空,返回诚实兜底。
if c, cerr := a.client.Generate(ctx, history); cerr == nil {
c = strings.TrimSpace(c)
if looksLikeToolEcho(c) {
c = stripToolEcho(c)
}
if c != "" {
return c, usage, a.client.Route(), nil
}
}
// 模型在「文本回显工具调用」上打转且未产出任何结论文本:低风险下不能凭空生成
// 数据(那会违背诚实性),因此返回明确的兜底说明,而不是把原始工具标签抛给用户,
// 也避免伪装成成功结果。
return toolEchoFallback, usage, a.client.Route(), nil
}
if out.Content == "" {
return "", usage, a.client.Route(), fmt.Errorf("模型最终回复为空")
}
return out.Content, usage, a.client.Route(), nil
}
// 工具死循环收敛:阻止模型反复发工具调用却迟迟不写结论文本。
// 即使每轮也带部分 content(如大纲标题),超过阈值轮仍未产出最终正文,即视为轮转过量,
// 先一步中断并走末尾的无工具纯文本收敛,避免在 240s 预算内空耗搜索轮次。
toolRounds++
if toolRounds >= 5 {
break
}
// 依次执行工具并回填结果
for _, tc := range out.ToolCalls {
tool, ok := a.tools[tc.Function.Name]
if !ok {
return "", usage, a.client.Route(), fmt.Errorf("模型请求了未注册的工具: %s", tc.Function.Name)
}
argsBytes := normalizeToolArguments(tc.Function.Arguments)
fmt.Fprintf(debugWriter, "[agent][diag] 执行工具 %s args=%s\n", tc.Function.Name, truncate(string(argsBytes), 160))
result, execErr := tool.Execute(ctx, argsBytes)
fmt.Fprintf(debugWriter, "[agent][diag] -> err=%v result_head=%q\n", execErr, truncate(result, 120))
if execErr != nil {
result = fmt.Sprintf("工具执行失败: %v", execErr)
}
history = append(history, Message{
Role: "tool",
ToolCallID: tc.ID,
Content: result,
})
}
}
// 工具死循环/超轮收敛:用无工具纯文本基于「已完成工具结果」重生成一次,作为最终结论,
// 避免 worker 或单问联网因模型反复 tool_calls 而不产文本。受 ctx 约束。
// 与上方的工具回显收敛同对待:模型惯性可能使收敛结果仍是 <|| …> 标签,需剥除再返回。
if len(a.toolSchemas) > 0 {
if content, cerr := a.client.Generate(ctx, history); cerr == nil {
content = strings.TrimSpace(content)
if looksLikeToolEcho(content) {
content = stripToolEcho(content)
}
if content != "" {
return content, usage, a.client.Route(), nil
}
// Generate 成功却只剩工具标签/空白:不凭空编造,返回诚实兜底而非升级 error。
return toolEchoFallback, usage, a.client.Route(), nil
}
}
return "", usage, a.client.Route(), fmt.Errorf("工具调用超过最大轮数 %d,可能死循环", a.maxRounds)
}
// looksLikeToolEcho 判断文本是否「工具调用回显」——即模型把工具调用意图以
// 文本/标签形式输出(Anthropic style tool_use 或其转义),而非规范 tool_calls。
// 命中后调用方应避免把这串原始标签当最终答案返回。
//
// 判据保持保守:必须命中强特征才判定,避免误伤正常含这些词组的正文。
// - 「<|」(DeepSeek 对 Anthropic tool_use 块的非标准 Unicode 转义)几乎不会
// 出现在正常中文自然语言里,出现即基本确定为工具回显;
// - invoke name="..."(Anthropic 工具调用块的标准文本形式)。
func looksLikeToolEcho(s string) bool {
if strings.Contains(s, "|") {
return true
}
if strings.Contains(s, "invoke name=") {
return true
}
return false
}
// 工具回显剥离用的正则:
// - line:任何以 <||>(转义形式,含开闭标签 <||/>、<|| invoke/…</>)开头的整行,
// 逐行删除(工具标签通常独占一行,不留可见噪声);
// - invokePara:标准 Anthropic 文本形式 <invoke>…</invoke>、<parameter>…</parameter> 行;
// - blank:折叠连续空行。
var (
reToolEchoLine = regexp.MustCompile(`(?m)^\s*</?[||][||][^>]*>.*$`)
reToolInvoke = regexp.MustCompile(`(?m)^\s*</?invoke\b.*$`)
reToolPara = regexp.MustCompile(`(?m)^\s*</?parameter\b.*$`)
reToolBlank = regexp.MustCompile(`\n{3,}`)
)
// toolEchoFallback:当模型深陷「文本回显工具调用」并最终未能产出结论文本时的诚实兜底。
// 不凭空编造数据(对齐项目「搜索诚实性」价值观),也不把工具标签抛给用户。
const toolEchoFallback = "当前检索未能获取有效内容。为避免给出未经核实的信息,建议直接访问权威来源(如央行、交易所、官方机构官网)核实,或稍后重试。"
// 文本格式工具调用解析正则:
// - invokeEcho:一整块 <|| invoke name="X">…</|| /invoke>(DeepSeek 对 Anthropic 文本
// tool_use 的非标准转义);同时也兼容标准 <invoke name="X">…</invoke>。
// - param:块内的 <|| parameter name="K"…>V</|| parameter>。
var (
reToolInvokeEcho = regexp.MustCompile(`(?s)<[||][||]?\s*invoke\b.*?</[||][||]?\s*/?invoke\s*>`)
reToolParamEcho = regexp.MustCompile(`(?s)<[||][||]?\s*parameter\s+name="([^"]+)"[^>]*>(.*?)</[||][||]?\s*/?parameter\s*>`)
reToolNameInEcho = regexp.MustCompile(`name="([^"]+)"`)
)
// parseToolEcho 把 Anthropic 文本格式的工具调用块(<|| invoke name="…">…</|| /invoke>)
// 解析成规范 tool_calls(真正触发工具执行),并从 content 中移除这些块只留自然语言。
// 仅当被调用的工具名存在于 allowed(已注册工具集)时才转为 tool_call;未注册的保留原文,
// 避免执行到未知工具而报错。
func parseToolEcho(content string, allowed map[string]bool) (string, []ToolCall) {
var calls []ToolCall
clean := reToolInvokeEcho.ReplaceAllStringFunc(content, func(block string) string {
m := reToolNameInEcho.FindStringSubmatch(block)
if len(m) < 2 {
return block // 解析不出 name,保留
}
if !allowed[strings.TrimSpace(m[1])] {
return block // 未注册工具,保留原文,不进执行环
}
args := map[string]string{}
for _, pm := range reToolParamEcho.FindAllStringSubmatch(block, -1) {
args[strings.TrimSpace(pm[1])] = pm[2]
}
argsJSON, err := json.Marshal(args)
if err != nil {
return block
}
calls = append(calls, ToolCall{
ID: fmt.Sprintf("call_echo_%d", len(calls)),
Type: "function",
Function: ToolFuncCall{
Name: strings.TrimSpace(m[1]),
Arguments: argsJSON,
},
})
return "" // 删除该工具块,只留纯文本
})
// 收尾:移除残余的工具包裹标签(如 <|| calls>…</|| /calls>)及其它行级标签,
// 只留下自然语言正文。
clean = stripToolEcho(clean)
return clean, calls
}
// stripToolEcho 从文本中剥除 Anthropic 风格的工具调用标签(DeepSeek 转义 <||> 或
// 标准 <invoke>/<parameter>),只保留自然语言正文。全部为标签时返回空串。
func stripToolEcho(s string) string {
s = reToolEchoLine.ReplaceAllString(s, "")
s = reToolInvoke.ReplaceAllString(s, "")
s = reToolPara.ReplaceAllString(s, "")
s = reToolBlank.ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}
// normalizeToolArguments 处理 arguments 的两种形态:
// - 标准:{"query": "..."},直接返回;
// - 部分 provider 把 arguments 作为 JSON 字符串返回,即 "{\"query\":\"...\"}",
// 此时先 Unmarshal 成 raw 字节再加解析。存在 key 形如 query 的直接对象即可。
func normalizeToolArguments(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
return raw
}
trimmed := bytes.TrimSpace(raw)
// 若非字符串字面量(不以 " 开头),视为已是对象
if len(trimmed) == 0 || trimmed[0] != '"' {
return trimmed
}
// arguments 是 JSON 字符串 → 解开一层
var s string
if err := json.Unmarshal(trimmed, &s); err == nil {
return json.RawMessage(s)
}
return trimmed
}
// roundWithEmptyRecovery 发起一轮带工具的模型调用,并对「空响应」做多策略恢复:
//
// 1. 策略一:原始带工具调用。只要模型返回内容或工具调用即为合法响应,直接返回;
// 2. 策略二:仍为空则带工具重试一次(极轻退避,吸收偶发空回复);
// 3. 策略三:仍为空则降级为「不带工具」的纯文本重试一次,兼容「带工具即空、纯文本正常」
// 的路由(如部分模型对 web_search tools 支持性差)。这是保底,保证调用方拿到非空文本。
//
// 所有策略均受 ctx 约束,ctx 取消立即返回,不阻塞上层(编排 150s 兜底)。
func (a *Agent) roundWithEmptyRecovery(ctx context.Context, history []Message) (*ChatResult, error) {
// 策略一:原始带工具调用
out, err := a.client.generateWithTools(ctx, history, a.toolSchemas)
if err != nil {
return nil, err
}
if out.Content != "" || len(out.ToolCalls) > 0 {
return out, nil
}
lastErr := fmt.Errorf("模型返回空响应")
// 策略二:带工具重试一次
if len(a.toolSchemas) > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(300 * time.Millisecond):
}
if out2, err2 := a.client.generateWithTools(ctx, history, a.toolSchemas); err2 == nil && (out2.Content != "" || len(out2.ToolCalls) > 0) {
return out2, nil
} else if err2 != nil {
lastErr = err2
}
}
// 策略三:降级为不带工具的纯文本重试
if len(a.toolSchemas) > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(300 * time.Millisecond):
}
if content, err3 := a.client.Generate(ctx, history); err3 == nil && strings.TrimSpace(content) != "" {
return &ChatResult{Content: content}, nil
} else if err3 != nil {
lastErr = err3
}
}
return nil, fmt.Errorf("模型最终回复为空(带工具重试与无工具降级均未产出内容): %v", lastErr)
}
// generateWithTools 单轮调用:携带 tools 并解析 tool_calls。
// ctx 贯穿到 HTTP 请求,使 Agent 的多轮 tool_calls 都能被上层超时(如编排 150s 兜底)真正取消。
func (c *Client) generateWithTools(ctx context.Context, messages []Message, toolSchemas []ToolSchema) (*ChatResult, error) {
reqBody := map[string]any{
"model": c.model,
"messages": messages,
"stream": false,
"temperature": c.temperature,
"max_tokens": c.maxTokens,
}
if len(toolSchemas) > 0 {
reqBody["tools"] = toolSchemas
}
resp, err := c.post(ctx, "/chat/completions", reqBody)
if err != nil {
return nil, fmt.Errorf("LLM 服务不可达: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 LLM 响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("LLM 返回 %d: %s", resp.StatusCode, truncate(string(body), 200))
}
var raw struct {
Model string `json:"model"`
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("LLM 响应解析失败: %w", err)
}
if len(raw.Choices) == 0 {
return nil, fmt.Errorf("LLM 未返回任何 choice")
}
msg := raw.Choices[0].Message
content := msg.Content
toolCalls := msg.ToolCalls
// 兜底解析「文本格式工具调用」:LMUAI/DeepSeek 等兼容 Anthropic 的 provider 常把工具
// 调用以 <|| invoke name="…">…</|| /invoke> 文本而非规范 tool_calls JSON 返回。
// 此时 ToolCalls 为空、Content 是原始标签,若直接返回就把噪声抛给用户、搜索也未真正执行。
// 这里把文本块解析回规范 tool_calls(触发 Agent 真正运行 web_search),并清理出纯文本。
if len(toolCalls) == 0 && content != "" && looksLikeToolEcho(content) {
allowed := make(map[string]bool, len(toolSchemas))
for _, ts := range toolSchemas {
allowed[ts.Function.Name] = true
}
clean, parsed := parseToolEcho(content, allowed)
if len(parsed) > 0 {
content = clean
toolCalls = parsed
}
}
result := &ChatResult{
Content: content,
Model: raw.Model,
FinishReason: raw.Choices[0].FinishReason,
ToolCalls: toolCalls,
}
if raw.Usage != nil {
result.Usage.PromptTokens = raw.Usage.PromptTokens
result.Usage.CompletionTokens = raw.Usage.CompletionTokens
result.Usage.TotalTokens = raw.Usage.TotalTokens
}
return result, nil
}
@@ -0,0 +1,37 @@
package ai
import (
"encoding/json"
"testing"
)
func TestNormalizeToolArguments(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"标准对象", `{"query":"外骨骼"}`, `{"query":"外骨骼"}`},
{"JSON字符串", `"{\"query\":\"外骨骼\"}"`, `{"query":"外骨骼"}`},
{"空", ``, ``},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := string(normalizeToolArguments(json.RawMessage(c.in)))
if got != c.want {
t.Errorf("normalizeToolArguments(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
func TestWebSearchToolSchema(t *testing.T) {
tool := NewWebSearchTool(nil)
s := tool.Schema()
if s.Type != "function" || s.Function.Name != "web_search" {
t.Fatalf("unexpected schema: type=%s name=%s", s.Type, s.Function.Name)
}
if _, err := json.Marshal(s); err != nil {
t.Fatalf("schema not marshallable: %v", err)
}
}
@@ -0,0 +1,134 @@
package ai
import (
"encoding/json"
"strings"
"testing"
)
// TestLooksLikeToolEcho 验证工具回显指纹的判定:命中(Anthropic 转义标签 / invoke 文本)
// 应返回 true,正常自然语言正文不应误伤。
func TestLooksLikeToolEcho(t *testing.T) {
positive := []string{
// LMUAI/DeepSeek 实测泄漏的原始转义
"<||DSML|| calls>\n<||DSML|| invoke name=\"web_search\">\n<||DSML|| parameter name=\"query\"",
"<|| calls>\n<|| invoke name=\"web_search\">",
// 标准 Anthropic 文本工具调用
`<invoke name="web_search">
<parameter name="query">汇率</parameter>
</invoke>`,
}
for _, s := range positive {
if !looksLikeToolEcho(s) {
t.Errorf("应判定为工具回显,实际 false: %q", truncateGo(s, 60))
}
}
negative := []string{
"汇率是重要的经济指标。",
"关于人民币对美元汇率,请参考央行官网。",
`{"query":"web_search 调用测试"}`,
}
for _, s := range negative {
if looksLikeToolEcho(s) {
t.Errorf("正常文本被误判为工具回显: %q", truncateGo(s, 60))
}
}
}
// TestStripToolEcho 验证把工具回显标签剥除后只留自然语言,纯标签应清空。
func TestStripToolEcho(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "整块工具回显被剥离",
in: "<|| calls>\n<|| invoke name=\"web_search\">\n<|| parameter name=\"query\" string=\"true\">汇率</|| parameter>\n<|| /invoke>\n<|| /calls>",
want: "",
},
{
name: "块前自然语言保留",
in: "我需要先查一下汇率\n<|| calls>\n<|| invoke name=\"web_search\"></|| /calls>",
want: "我需要先查一下汇率",
},
{
name: "块后自然语言保留",
in: "<|| calls>\n<|| invoke name=\"web_search\">\n<|| parameter name=\"query\" string=\"true\">汇率</|| parameter>\n<|| /invoke>\n<|| /calls>\n这是根据检索整理的汇率结论。",
want: "这是根据检索整理的汇率结论。",
},
{
name: "残留闭合标签被清空",
in: "\n</|| invoke>\n\n</|| invoke>\n</|| calls>",
want: "",
},
{
name: "正常文本不动",
in: "人民币对美元汇率请参考央行官网。",
want: "人民币对美元汇率请参考央行官网。",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := stripToolEcho(c.in); got != c.want {
t.Errorf("stripToolEcho 不符:\n got=%q\nwant=%q", got, c.want)
}
})
}
}
// TestParseToolEcho 验证把 Anthropic 文本格式工具调用解析成规范 tool_calls,
// 且清理出纯文本、未注册工具不被误转换。
func TestParseToolEcho(t *testing.T) {
allowed := map[string]bool{"web_search": true}
// 真实场景输出样式
in := `我需要先确认最新汇率
<|| calls>
<|| invoke name="web_search">
<|| parameter name="query" string="true">"9月25日" "人民币" 中间价 2026</|| parameter>
</|| invoke>
<|| /calls>`
clean, calls := parseToolEcho(in, allowed)
if len(calls) != 1 {
t.Fatalf("应解析出 1 个 tool_call,实得 %d", len(calls))
}
if calls[0].Function.Name != "web_search" {
t.Errorf("工具名应为 web_search,实得 %s", calls[0].Function.Name)
}
var args struct {
Query string `json:"query"`
}
if err := json.Unmarshal(calls[0].Function.Arguments, &args); err != nil {
t.Fatalf("arguments 解析失败: %v", err)
}
if !strings.Contains(args.Query, "人民币") {
t.Errorf("query 应含『人民币』,实得 %q", args.Query)
}
if strings.Contains(clean, "|") {
t.Errorf("clean 不应残留工具标签: %q", clean)
}
if !strings.Contains(clean, "最新汇率") {
t.Errorf("clean 应保留块前自然语言: %q", clean)
}
// 未注册工具:不产生 tool_call,且其标签块应被剥离(不得把噪声暴露给用户)。
unknown := `<|| invoke name="browser_open">
<|| parameter name="url">https://x.com</|| parameter>
</|| invoke>`
clean2, calls2 := parseToolEcho(unknown, allowed)
if len(calls2) != 0 {
t.Errorf("未注册工具不应转 tool_call,实得 %d", len(calls2))
}
if strings.Contains(clean2, "|") || strings.Contains(clean2, "invoke") {
t.Errorf("未注册工具块不应残留标签噪声: %q", clean2)
}
}
func truncateGo(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
@@ -15,29 +15,29 @@ import (
// AI 用量类型常量
const (
UsageKindAIChat = "ai_chat" // PathCoach 对话(每轮扣 1 点)
UsageKindTextGen = "text_gen" // 快捷动作(情景演练/查佣金/产品对比,扣 1 点)
UsageKindImageGen = "image_gen" // 文生图(按次扣点)
UsageKindEmbed = "embed" // 知识检索内部 embedding(不扣点,仅审计)
UsageKindEssayGrade = "essay_grade" // 简答题 LLM 评分(系统自动,不扣点,仅审计)
UsageKindAIChat = "ai_chat" // PathCoach 对话(每轮扣 1 点)
UsageKindTextGen = "text_gen" // 快捷动作(情景演练/查佣金/产品对比,扣 1 点)
UsageKindImageGen = "image_gen" // 文生图(按次扣点)
UsageKindEmbed = "embed" // 知识检索内部 embedding(不扣点,仅审计)
UsageKindEssayGrade = "essay_grade" // 简答题 LLM 评分(系统自动,不扣点,仅审计)
// 以下 5 类为工具型 AI 调用,当前只审计、不扣点:
// 它们不在 UsageKindCredits 表里,ComputeCredits 查不到即返回 0。
// 将来若要计费,只需把它们加进 UsageKindCredits,扣点逻辑自动生效。
UsageKindBatchExtract = "batch_extract"
UsageKindContractReview = "contract_review"
UsageKindAudioTranscribe = "audio_transcribe"
UsageKindCopyProofread = "copy_proofread"
UsageKindDocumentTranslate = "document_translate"
// 以下 5 类为工具型 AI 调用,当前只审计、不扣点:
// 它们不在 UsageKindCredits 表里,ComputeCredits 查不到即返回 0。
// 将来若要计费,只需把它们加进 UsageKindCredits,扣点逻辑自动生效。
UsageKindBatchExtract = "batch_extract"
UsageKindContractReview = "contract_review"
UsageKindAudioTranscribe = "audio_transcribe"
UsageKindCopyProofread = "copy_proofread"
UsageKindDocumentTranslate = "document_translate"
)
// UsageKindCredits 各用量类型扣点成本
var UsageKindCredits = map[string]int{
UsageKindAIChat: 1,
UsageKindTextGen: 1,
UsageKindImageGen: 1,
UsageKindEmbed: 0,
UsageKindEssayGrade: 0,
UsageKindAIChat: 1,
UsageKindTextGen: 1,
UsageKindImageGen: 1,
UsageKindEmbed: 0,
UsageKindEssayGrade: 0,
}
// ComputeCredits 扣点决策:仅「成功」才扣点;失败不扣。
@@ -45,13 +45,13 @@ func ComputeCredits(usageKind string, success bool) int {
if !success {
return 0
}
return UsageKindCredits[usageKind]
return UsageKindCredits[usageKind]
}
// LogEntry 一次 AI 调用的审计入参
type LogEntry struct {
UserID uint
UsageKind string
UserID uint
UsageKind string
// SpecialistKey 本次调用以哪个专员的身份进行;空 = 通用助手
SpecialistKey string
Provider string
@@ -66,14 +66,19 @@ type LogEntry struct {
// LogCall 写 ai_call_log;成功且需扣点时从用户余额扣点。审计写入失败不阻断主流程。
func LogCall(e LogEntry) {
credits := ComputeCredits(e.UsageKind, e.Success)
// 数据库尚未初始化(如测试、独立 worker、无 DB 的嵌入场景)时直接跳过审计,
// 绝不因写入 ai_call_log 的指针解引用而 panic 拖垮 AI 主链路。
if store.DB == nil {
return
}
credits := ComputeCredits(e.UsageKind, e.Success)
status := "success"
if !e.Success {
status = "failed"
}
rec := model.AiCallLog{
UserID: e.UserID,
UsageKind: e.UsageKind,
UsageKind: e.UsageKind,
SpecialistKey: e.SpecialistKey,
Provider: e.Provider,
AIRouteID: e.AIRouteID,
+180 -40
View File
@@ -3,7 +3,9 @@ package ai
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -18,9 +20,37 @@ import (
// ──────────────────────────────────────────────
// Message OpenAI 兼容消息
// Role: system / user / assistant / tool。
// 普通对话仅需 Role+Content;工具调用场景可带 ToolCalls(assistant 发起)
// 与 ToolCallID(回填 tool 结果时使用)。
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// ToolCall 模型发起的工具调用。
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type,omitempty"`
Function ToolFuncCall `json:"function"`
}
// ToolFuncCall 工具调用的函数名与参数(json.RawMessage 以保持参数原文)。
type ToolFuncCall struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
// ToolSchema OpenAI 兼容 function tool 定义,随请求一并提交给 LLM。
type ToolSchema struct {
Type string `json:"type"` // "function"
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters interface{} `json:"parameters"`
} `json:"function"`
}
// ChatResult 非流式调用结果
@@ -29,6 +59,7 @@ type ChatResult struct {
Model string
Provider string
FinishReason string
ToolCalls []ToolCall
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
@@ -42,6 +73,7 @@ type ChatResult struct {
// Client 低层 HTTP 客户端(基于 RouteConfig)
type Client struct {
route *config.RouteConfig
baseURL string
apiKey string
model string
@@ -50,6 +82,11 @@ type Client struct {
hc *http.Client
}
// Route 返回创建本客户端的路由(可能为 nil)。
func (c *Client) Route() *config.RouteConfig {
return c.route
}
// defaultHTTPTimeout 默认 HTTP 整体超时;路由可用 timeout_seconds 单独覆盖
const defaultHTTPTimeout = 60 * time.Second
@@ -60,6 +97,7 @@ func NewClient(aiRoute *config.RouteConfig) *Client {
timeout = time.Duration(aiRoute.TimeoutSeconds) * time.Second
}
return &Client{
route: aiRoute,
baseURL: strings.TrimRight(aiRoute.BaseURL, "/"),
apiKey: aiRoute.APIKey,
model: aiRoute.Model,
@@ -81,12 +119,14 @@ func (c *Client) headers() map[string]string {
return h
}
func (c *Client) post(path string, body any) (*http.Response, error) {
// post 发起 OpenAI 兼容请求。ctx 可被上层(如编排 150s 兜底)用 WithTimeout 取消,
// 使 LLM 调用真正中断,而不是只能等死的 http.Client.Timeout。
func (c *Client) post(ctx context.Context, path string, body any) (*http.Response, error) {
j, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, c.url(path), bytes.NewReader(j))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url(path), bytes.NewReader(j))
if err != nil {
return nil, err
}
@@ -102,11 +142,81 @@ func (c *Client) post(path string, body any) (*http.Response, error) {
// ──────────────────────────────────────────────
// 非流式调用
// EmptyCompletionError 上游回了 200,但正文是空的。
//
// 为什么要单独成类型:**「空正文」有好几种成因,处置完全不同,而 finish_reason
// 是唯一能把它们分开的东西**。
//
// 最要命的一种是推理模型把 max_tokens 全花在思考上 —— 此时响应里**根本没有
// content 字段**(不是空串,是不存在),finish_reason=length。实测某对话路由
// (max_tokens=4096)对 2500 字的密集中文输入,reasoning_content 写了 7187 字、
// 正文 0 字;把预算提到 8192 才有正文(2470 字,reasoning 9590 字)。
// 这种情况报「空正文」会把人引去查模型名和密钥,而真正该做的是调大预算;
// 报「思考吃光了预算」才能一眼看到病根。
//
// 只由 GenerateFull 返回(Generate 只回字符串,拿不到 finish_reason)。
type EmptyCompletionError struct {
FinishReason string
CompletionTokens int
}
// Error 保留「LLM 返回空正文」前缀:这是这条例外此前的文案,外部可能有按
// 子串匹配的地方,补上诊断信息不该顺手改掉已经存在的说法。
func (e *EmptyCompletionError) Error() string {
if e.FinishReason == "" {
return "LLM 返回空正文(响应里没有 finish_reason)"
}
if strings.EqualFold(e.FinishReason, "length") {
return fmt.Sprintf(
"LLM 返回空正文(finish_reason=length,completion_tokens=%d):预算被思考用光了,"+
"推理模型写正文之前就把 max_tokens 花完了,调大该路由的 max_tokens 才可能写出正文",
e.CompletionTokens)
}
return fmt.Sprintf("LLM 返回空正文(finish_reason=%q,completion_tokens=%d)", e.FinishReason, e.CompletionTokens)
}
// TransientUpstreamError 上游这一次没成,但**同样的请求重发有机会成**。
//
// 与 EmptyCompletionError 的区别就是「要不要重试」这一件事,而这件事必须由类型
// 决定、不能由文案猜:
//
// - EmptyCompletionError 是模型确实写不出正文(预算被思考吃光、模型拒答),
// 重发一百次都是同一个结果,重试只是再花一次钱;
// - 本类型是**协议或链路层面的抖动** —— 网关回了 200 却给一个没有 choices 的
// 报文、限流、上游 5xx、连接被掐断。这些重发就有机会成,不重发则整步白跑。
//
// 实测背景:某技能的第 3 步要连打 11 次模型(逐字稿按 1200 字分块),第 4 次
// 撞上的就是这个 —— 网关 HTTP 200,报文里却没有 choices,整步就此失败。
type TransientUpstreamError struct {
// StatusCode 0 表示压根没拿到状态码(连接就没建起来)。
// 目前只用来说明情况,重试决策在构造处就定了。
StatusCode int
// Message 是**原样的**错误文案。故意不做二次拼装:这些文案是既有说法,
// 记录日志、前端提示都可能在看,加个类型标记没理由顺手改动它们。
Message string
// Err 保留底层错误,让 errors.Is/As 还能穿透下去
// (例如上层要判 context.DeadlineExceeded)。此前靠 %w 提供的这条链不能断。
Err error
}
func (e *TransientUpstreamError) Error() string { return e.Message }
func (e *TransientUpstreamError) Unwrap() error { return e.Err }
// IsTransient 判断 err 值不值得重发。
//
// 走 errors.As,所以**经过 %w 包装后依然认得出** —— 回退链会给错误套上
// 「所有路由均失败: [路由 id] …」两层包装,调用方拿到的是最外层那个。
func IsTransient(err error) bool {
var transient *TransientUpstreamError
return errors.As(err, &transient)
}
// ──────────────────────────────────────────────
// Generate 非流式对话,返回完整正文
func (c *Client) Generate(messages []Message) (string, error) {
resp, err := c.post("/chat/completions", map[string]any{
func (c *Client) Generate(ctx context.Context, messages []Message) (string, error) {
resp, err := c.post(ctx, "/chat/completions", map[string]any{
"model": c.model,
"messages": messages,
"stream": false,
@@ -143,8 +253,8 @@ func (c *Client) Generate(messages []Message) (string, error) {
}
// GenerateFull 非流式调用,返回完整 ChatResult(含 usage)
func (c *Client) GenerateFull(messages []Message) (*ChatResult, error) {
resp, err := c.post("/chat/completions", map[string]any{
func (c *Client) GenerateFull(ctx context.Context, messages []Message) (*ChatResult, error) {
resp, err := c.post(ctx, "/chat/completions", map[string]any{
"model": c.model,
"messages": messages,
"stream": false,
@@ -152,16 +262,30 @@ func (c *Client) GenerateFull(messages []Message) (*ChatResult, error) {
"max_tokens": c.maxTokens,
})
if err != nil {
return nil, fmt.Errorf("LLM 服务不可达: %w", err)
// 连接建不起来 / 被掐断 / 超时:重发有意义。
return nil, &TransientUpstreamError{
Message: fmt.Sprintf("LLM 服务不可达: %v", err),
Err: err,
}
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 LLM 响应失败: %w", err)
// 正文读到一半断了,同样属于这一趟的抖动。
return nil, &TransientUpstreamError{
Message: fmt.Sprintf("读取 LLM 响应失败: %v", err),
Err: err,
}
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("LLM 返回 %d: %s", resp.StatusCode, truncate(string(data), 200))
msg := fmt.Sprintf("LLM 返回 %d: %s", resp.StatusCode, truncate(string(data), 200))
// 限流和上游 5xx 是**这一趟**的问题,重发可能就过了;
// 4xx(模型名写错、密钥无效、参数非法)重发一百次也是同一个结果,不标可重试。
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
return nil, &TransientUpstreamError{StatusCode: resp.StatusCode, Message: msg}
}
return nil, errors.New(msg)
}
var raw struct {
@@ -179,10 +303,34 @@ func (c *Client) GenerateFull(messages []Message) (*ChatResult, error) {
} `json:"usage"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("LLM 响应解析失败: %w", err)
// 报文根本不是 JSON:与「没有 choices」同一种病 —— 网关塞了别的东西进来。
return nil, &TransientUpstreamError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("LLM 响应解析失败: %v", err),
Err: err,
}
}
if len(raw.Choices) == 0 || strings.TrimSpace(raw.Choices[0].Message.Content) == "" {
return nil, fmt.Errorf("LLM 返回空正文")
if len(raw.Choices) == 0 {
// 没有 choices 是**协议层面**的异常,跟「模型没写出正文」不是一回事:
// 网关回 200 却是别的报文形状(限流、上游报错包在 200 里等)都会落到这里。
// 所以把原始报文带上 —— 只说一句「空正文」,拿到的人根本无从下手。
// 与上面状态码分支一样截断,且保留「LLM 返回空正文」这个前缀。
//
// 标成可重试:报文形状不对是**这一趟**的事,不是模型对这个输入的回答。
return nil, &TransientUpstreamError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("LLM 返回空正文(响应里没有 choices):%s", truncate(string(data), 300)),
}
}
if strings.TrimSpace(raw.Choices[0].Message.Content) == "" {
completionTokens := 0
if raw.Usage != nil {
completionTokens = raw.Usage.CompletionTokens
}
return nil, &EmptyCompletionError{
FinishReason: raw.Choices[0].FinishReason,
CompletionTokens: completionTokens,
}
}
result := &ChatResult{
@@ -203,8 +351,8 @@ func (c *Client) GenerateFull(messages []Message) (*ChatResult, error) {
// ──────────────────────────────────────────────
// GenerateStream SSE 流式调用,逐 chunk 回调
func (c *Client) GenerateStream(messages []Message, onChunk func(string)) error {
resp, err := c.post("/chat/completions", map[string]any{
func (c *Client) GenerateStream(ctx context.Context, messages []Message, onChunk func(string)) error {
resp, err := c.post(ctx, "/chat/completions", map[string]any{
"model": c.model,
"messages": messages,
"stream": true,
@@ -283,8 +431,8 @@ func (c *Client) GenerateStream(messages []Message, onChunk func(string)) error
// ──────────────────────────────────────────────
// Embed 批量向量化
func (c *Client) Embed(inputs []string) ([][]float64, error) {
resp, err := c.post("/embeddings", map[string]any{
func (c *Client) Embed(ctx context.Context, inputs []string) ([][]float64, error) {
resp, err := c.post(ctx, "/embeddings", map[string]any{
"model": c.model, "input": inputs,
})
if err != nil {
@@ -335,8 +483,9 @@ func buildRouteChain(primary *config.RouteConfig) ([]*config.RouteConfig, error)
return aiRouteChain, nil
}
// GenerateWithFallback 依次尝试主路由 + 回退链,首个成功返回
func GenerateWithFallback(primary *config.RouteConfig, messages []Message) (string, error) {
// GenerateWithFallback 依次尝试主路由 + 回退链,首个成功返回。
// ctx 贯穿到各路由的 HTTP 请求,使上层超时(如编排 150s 兜底)真正能取消 LLM 调用。
func GenerateWithFallback(ctx context.Context, primary *config.RouteConfig, messages []Message) (string, error) {
aiRouteChain, err := buildRouteChain(primary)
if err != nil {
return "", err
@@ -346,7 +495,7 @@ func GenerateWithFallback(primary *config.RouteConfig, messages []Message) (stri
for _, aiRoute := range aiRouteChain {
client := NewClient(aiRoute)
start := time.Now()
content, err := client.Generate(messages)
content, err := client.Generate(ctx, messages)
if err == nil {
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: aiRoute.RouteID,
@@ -372,8 +521,9 @@ func GenerateWithFallback(primary *config.RouteConfig, messages []Message) (stri
return "", fmt.Errorf("所有路由均失败: %w", lastErr)
}
// GenerateFullWithFallback 非流式 + 回退链:返回结果与「实际命中」的路由(用于审计)
func GenerateFullWithFallback(primary *config.RouteConfig, messages []Message) (*ChatResult, *config.RouteConfig, error) {
// GenerateFullWithFallback 非流式 + 回退链:返回结果与「实际命中」的路由(用于审计)。
// ctx 贯穿到各路由的 HTTP 请求,使上层超时真正能取消 LLM 调用。
func GenerateFullWithFallback(ctx context.Context, primary *config.RouteConfig, messages []Message) (*ChatResult, *config.RouteConfig, error) {
aiRouteChain, err := buildRouteChain(primary)
if err != nil {
return nil, nil, err
@@ -383,7 +533,7 @@ func GenerateFullWithFallback(primary *config.RouteConfig, messages []Message) (
for _, aiRoute := range aiRouteChain {
client := NewClient(aiRoute)
start := time.Now()
res, err := client.GenerateFull(messages)
res, err := client.GenerateFull(ctx, messages)
if err == nil {
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: aiRoute.RouteID,
@@ -410,8 +560,9 @@ func GenerateFullWithFallback(primary *config.RouteConfig, messages []Message) (
return nil, nil, fmt.Errorf("所有路由均失败: %w", lastErr)
}
// GenerateStreamWithFallback 流式 + 回退链:返回实际命中的路由
func GenerateStreamWithFallback(primary *config.RouteConfig, messages []Message, onChunk func(string)) (*config.RouteConfig, error) {
// GenerateStreamWithFallback 流式 + 回退链:返回实际命中的路由。
// ctx 贯穿到各路由的 SSE 请求,使上层超时(如前端断开)真正能取消 LLM 调用。
func GenerateStreamWithFallback(ctx context.Context, primary *config.RouteConfig, messages []Message, onChunk func(string)) (*config.RouteConfig, error) {
aiRouteChain, err := buildRouteChain(primary)
if err != nil {
return nil, err
@@ -419,7 +570,8 @@ func GenerateStreamWithFallback(primary *config.RouteConfig, messages []Message,
var lastErr error
for _, aiRoute := range aiRouteChain {
if requiresAPIKey(aiRoute) && strings.TrimSpace(aiRoute.APIKey) == "" {
// 判据只有 config 那一处,本文件原先自己抄了一份(缺「回环地址免密钥」)。
if config.RequiresRouteAPIKey(aiRoute) && strings.TrimSpace(aiRoute.APIKey) == "" {
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: aiRoute.RouteID,
Category: aiRoute.Category,
@@ -433,7 +585,7 @@ func GenerateStreamWithFallback(primary *config.RouteConfig, messages []Message,
}
client := NewClient(aiRoute)
start := time.Now()
if err := client.GenerateStream(messages, onChunk); err == nil {
if err := client.GenerateStream(ctx, messages, onChunk); err == nil {
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: aiRoute.RouteID,
Category: aiRoute.Category,
@@ -459,18 +611,6 @@ func GenerateStreamWithFallback(primary *config.RouteConfig, messages []Message,
return nil, fmt.Errorf("所有路由均失败: %w", lastErr)
}
func requiresAPIKey(aiRoute *config.RouteConfig) bool {
if aiRoute == nil {
return false
}
baseURL := strings.ToLower(strings.TrimSpace(aiRoute.BaseURL))
if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
return true
}
provider := strings.ToLower(strings.TrimSpace(aiRoute.Provider))
return provider == "openrouter" || provider == "openai"
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
@@ -1,6 +1,7 @@
package ai
import (
"context"
"strings"
"testing"
@@ -47,13 +48,13 @@ func TestGenerateWithFallbackNilPrimary(t *testing.T) {
msgs := []Message{{Role: "user", Content: "ping"}}
t.Run("GenerateWithFallback", func(t *testing.T) {
if _, err := GenerateWithFallback(nil, msgs); err == nil {
if _, err := GenerateWithFallback(context.Background(), nil, msgs); err == nil {
t.Error("期望返回错误,实际 nil")
}
})
t.Run("GenerateFullWithFallback", func(t *testing.T) {
res, route, err := GenerateFullWithFallback(nil, msgs)
res, route, err := GenerateFullWithFallback(context.Background(), nil, msgs)
if err == nil {
t.Error("期望返回错误,实际 nil")
}
@@ -63,7 +64,7 @@ func TestGenerateWithFallbackNilPrimary(t *testing.T) {
})
t.Run("GenerateStreamWithFallback", func(t *testing.T) {
if _, err := GenerateStreamWithFallback(nil, msgs, func(string) {}); err == nil {
if _, err := GenerateStreamWithFallback(context.Background(), nil, msgs, func(string) {}); err == nil {
t.Error("期望返回错误,实际 nil")
}
})
@@ -0,0 +1,183 @@
package ai
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"eai_agentplatform/backend/internal/config"
)
// newRouteServer 起一个把固定状态码与报文原样吐回的服务,并返回指向它的路由。
//
// 用真实 HTTP 而不是构造 error 再断言:分类动作就长在 GenerateFull 的分支里,
// 绕开它去测 IsTransient 等于什么都没验。
func newRouteServer(t *testing.T, status int, body string) *config.RouteConfig {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return &config.RouteConfig{RouteID: "chat_route_transient_test", BaseURL: srv.URL, Model: "test-model"}
}
// TestGenerateFullClassifiesTransientUpstreamFailures 钉住「哪些失败值得重发」这条分界。
//
// 分界的两种错法代价都很实在:
// - 该重试的没重试 → 第 3 步 11 段里挂 1 段,整步白跑(实测发生过,见下);
// - 不该重试的标成可重试 → 每次重发都在花钱,而确定性的失败重发一百次也一样。
func TestGenerateFullClassifiesTransientUpstreamFailures(t *testing.T) {
cases := []struct {
name string
// 实测第 3 步就是在第 4/11 段撞上这个:网关 HTTP 200,报文里却没有 choices。
status int
body string
wantTransient bool
wantInMsg string // 文案要保持原样,日志与前端提示都在看它
}{
{
name: "网关回 200 却没有 choices",
status: http.StatusOK,
body: `{"error":{"message":"upstream timeout","type":"upstream_error"}}`,
wantTransient: true,
wantInMsg: "LLM 返回空正文(响应里没有 choices)",
},
{
name: "网关回 200 但根本不是 JSON",
status: http.StatusOK,
body: `<html>502 Bad Gateway</html>`,
wantTransient: true,
wantInMsg: "LLM 响应解析失败",
},
{
name: "限流 429",
status: http.StatusTooManyRequests,
body: `{"error":"rate limited"}`,
wantTransient: true,
wantInMsg: "LLM 返回 429",
},
{
name: "上游 500",
status: http.StatusInternalServerError,
body: `{"error":"internal"}`,
wantTransient: true,
wantInMsg: "LLM 返回 500",
},
{
name: "上游 503",
status: http.StatusServiceUnavailable,
body: `{"error":"overloaded"}`,
wantTransient: true,
wantInMsg: "LLM 返回 503",
},
{
// 4xx 是「这个请求本身就不对」—— 模型名、密钥、参数。
// 重发一百次也是同一个结果,标成可重试只会白花钱。
name: "模型名写错 404",
status: http.StatusNotFound,
body: `{"error":"model not found"}`,
wantTransient: false,
wantInMsg: "LLM 返回 404",
},
{
name: "密钥无效 401",
status: http.StatusUnauthorized,
body: `{"error":"invalid key"}`,
wantTransient: false,
wantInMsg: "LLM 返回 401",
},
{
name: "参数非法 400",
status: http.StatusBadRequest,
body: `{"error":"bad request"}`,
wantTransient: false,
wantInMsg: "LLM 返回 400",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
route := newRouteServer(t, tc.status, tc.body)
_, err := NewClient(route).GenerateFull(context.Background(),
[]Message{{Role: "user", Content: "ping"}})
if err == nil {
t.Fatal("期望失败,实际成功")
}
if got := IsTransient(err); got != tc.wantTransient {
t.Errorf("IsTransient = %v,期望 %v(错误:%v)", got, tc.wantTransient, err)
}
if !strings.Contains(err.Error(), tc.wantInMsg) {
t.Errorf("文案里应保留 %q,实际为:%v", tc.wantInMsg, err)
}
})
}
}
// TestSuccessIsNotTransient 成功时不该有任何错误,这条是上面所有断言的对照组。
func TestSuccessIsNotTransient(t *testing.T) {
route := newRouteServer(t, http.StatusOK,
`{"model":"m","choices":[{"message":{"content":"正文"},"finish_reason":"stop"}]}`)
res, err := NewClient(route).GenerateFull(context.Background(),
[]Message{{Role: "user", Content: "ping"}})
if err != nil {
t.Fatalf("正常报文不该报错:%v", err)
}
if res.Content != "正文" {
t.Errorf("正文 = %q,期望 %q", res.Content, "正文")
}
if IsTransient(err) {
t.Error("nil 错误不该被判为可重试")
}
}
// TestBudgetExhaustionIsNotTransient 预算被思考吃光是**确定性**失败:
// choices 在、finish_reason=length,只是正文没有。它必须落进 EmptyCompletionError
// 而不是可重试那一类 —— 否则重试循环会对着同一个写不出正文的请求连打三次,白花三次钱。
func TestBudgetExhaustionIsNotTransient(t *testing.T) {
route := newRouteServer(t, http.StatusOK,
`{"model":"m","choices":[{"message":{"content":"","reasoning_content":"想了很多"},"finish_reason":"length"}],`+
`"usage":{"completion_tokens":4096}}`)
_, err := NewClient(route).GenerateFull(context.Background(),
[]Message{{Role: "user", Content: "ping"}})
if err == nil {
t.Fatal("预算耗尽且正文为空时应报错,实际成功")
}
var empty *EmptyCompletionError
if !errors.As(err, &empty) {
t.Fatalf("期望 EmptyCompletionError,实际 %T:%v", err, err)
}
if empty.CompletionTokens != 4096 {
t.Errorf("completion_tokens = %d,期望 4096", empty.CompletionTokens)
}
if IsTransient(err) {
t.Error("预算耗尽不该被判为可重试:重发拿的是同一个结果,只是再花一次钱")
}
}
// TestTransientSurvivesFallbackChainWrapping 回退链会给错误套上两层 %w 包装
// (「所有路由均失败: [路由 id] …」),而技能的重试循环拿到的就是最外层那个。
// 分类必须穿透包装 —— 穿透不了,重试就等于没加。
func TestTransientSurvivesFallbackChainWrapping(t *testing.T) {
route := newRouteServer(t, http.StatusOK, `{"error":"upstream exploded"}`)
// 用一个必然不存在的路由 id,链上就只有主路由这一条。
route.RouteID = "chat_route_does_not_exist_for_test"
_, _, err := GenerateFullWithFallback(context.Background(), route,
[]Message{{Role: "user", Content: "ping"}})
if err == nil {
t.Fatal("期望失败,实际成功")
}
if !strings.Contains(err.Error(), "所有路由均失败") {
t.Fatalf("错误应经回退链包装,实际:%v", err)
}
if !IsTransient(err) {
t.Errorf("包装后仍应判为可重试,实际不可重试:%v", err)
}
}
@@ -1,6 +1,7 @@
package ai
import (
"context"
"math"
"sort"
"strings"
@@ -52,7 +53,7 @@ func vectorRetrieve(aiRoute *config.RouteConfig, query string, topK int) ([]stri
for _, ch := range chunks {
inputs = append(inputs, ch.Content)
}
vecs, err := client.Embed(inputs)
vecs, err := client.Embed(context.Background(), inputs)
if err != nil || len(vecs) != len(inputs) {
if err != nil {
return nil, err
@@ -0,0 +1,253 @@
package routetest
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"eai_agentplatform/backend/internal/config"
)
// 这一组用例钉的是语音路由的通路测试。
//
// 背景:routetest 原先按 Category 分发,但只认 chat/embed/image/video,
// audio 落到 default,于是「测试语音路由」会把一个 /chat/completions 请求
// 打到 ASR 服务上。报回来的错(模型不存在、参数不合法)与真实原因
// (走错接口)毫无关系,而这个红叉会让人去改一条本来没问题的路由。
//
// 所以第一条断言就是「打到了转写终点」——不是「没报错」。
// audioStub 起一个桩,记录收到的请求,能同时应答转写与 chat 两种终点。
type audioStub struct {
srv *httptest.Server
audioCalls int
chatCalls int
lastPath string
lastAuth string
lastCt string
lastFormFile string
audioStatus int
}
func newAudioStub(t *testing.T) *audioStub {
t.Helper()
s := &audioStub{audioStatus: http.StatusOK}
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
// 按后缀认转写终点,这样用例可以换一个 Endpoint 验证它确实来自配置。
case strings.HasSuffix(r.URL.Path, "/transcriptions"):
s.audioCalls++
s.lastPath = r.URL.Path
s.lastAuth = r.Header.Get("Authorization")
s.lastCt = r.Header.Get("Content-Type")
s.lastFormFile = formFileName(r)
if s.audioStatus != http.StatusOK {
w.WriteHeader(s.audioStatus)
_, _ = w.Write([]byte(`{"error":{"message":"no speech detected"}}`))
return
}
_, _ = w.Write([]byte(`{"text":"","segments":[],"duration":1.0}`))
case strings.HasSuffix(r.URL.Path, "/chat/completions"):
s.chatCalls++
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"pong"}}]}`))
default:
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"unexpected path"}`))
}
}))
t.Cleanup(s.srv.Close)
return s
}
// formFileName 从 multipart 请求里取出文件部件的文件名。
func formFileName(r *http.Request) string {
mr, err := r.MultipartReader()
if err != nil {
return ""
}
for {
part, err := mr.NextPart()
if err != nil {
return ""
}
if part.FormName() == "file" {
return part.FileName()
}
}
}
// audioRoute 造一条指向桩的语音路由(回环地址 → IsLocalRoute 判为本地)。
func audioRoute(s *audioStub) *config.RouteConfig {
return &config.RouteConfig{
RouteID: "audio_route_stub",
Provider: "local_asr",
Category: "audio",
BaseURL: s.srv.URL,
Endpoint: "/audio/transcriptions",
FullURL: s.srv.URL + "/audio/transcriptions",
Model: "large-v3",
TimeoutSeconds: 30,
}
}
// TestAudioRouteTestHitsTranscriptionsEndpoint 是最重要的一条:
// audio 必须打到转写终点,绝不能落到 chat 的 default 分支。
func TestAudioRouteTestHitsTranscriptionsEndpoint(t *testing.T) {
stub := newAudioStub(t)
res := Do(audioRoute(stub), &TestOptions{})
if stub.chatCalls != 0 {
t.Errorf("语音路由被打了一个 chat 请求(%d 次)—— audio 落到了 default 分支", stub.chatCalls)
}
if stub.audioCalls != 1 {
t.Fatalf("转写终点被打了 %d 次,期望 1 次", stub.audioCalls)
}
if !res.Succeeded || len(res.Items) != 1 || !res.Items[0].Ok {
t.Fatalf("期望测试成功,实际 %+v", res.Items)
}
if !strings.HasPrefix(stub.lastCt, "multipart/form-data") {
t.Errorf("Content-Type = %q,期望 multipart/form-data", stub.lastCt)
}
if stub.lastFormFile != "silence-1s.wav" {
t.Errorf("文件部件名 = %q,期望 silence-1s.wav", stub.lastFormFile)
}
if !strings.Contains(res.Items[0].Summary, "静音") {
t.Errorf("摘要没说明样本是静音,会被读成「转写质量已验证」:%q", res.Items[0].Summary)
}
}
// TestAudioRouteTestUsesConfiguredEndpoint 终点取配置里的 Endpoint。
// 写死 /audio/transcriptions 会让这个可配置字段失效(云端中转路径未必一致)。
func TestAudioRouteTestUsesConfiguredEndpoint(t *testing.T) {
stub := newAudioStub(t)
route := audioRoute(stub)
route.Endpoint = "/v1/custom/transcriptions"
res := Do(route, &TestOptions{})
if !res.Items[0].Ok {
t.Fatalf("按 Endpoint 拼接的请求应当成功:%s", res.Items[0].Error)
}
if stub.lastPath != "/v1/custom/transcriptions" {
t.Errorf("打到了 %q —— 终点没有取自配置的 Endpoint", stub.lastPath)
}
}
// TestAudioRouteTestSendsBearerForLocalRoute 本地服务没有密钥也要带鉴权头。
//
// serve.py 不校验密钥的值,但收不到 Authorization 就回 401。通路测试若跳过这个头,
// 本地 ASR 会稳定测红 —— 而真实转写是通的(transcribe.go 里就写死 "local")。
func TestAudioRouteTestSendsBearerForLocalRoute(t *testing.T) {
stub := newAudioStub(t)
route := audioRoute(stub)
if route.APIKey != "" {
t.Fatal("用例前提:这条路由没有密钥")
}
res := Do(route, &TestOptions{})
if !res.Items[0].Ok {
t.Fatalf("本地无密钥路由应当能测通,实际:%s", res.Items[0].Error)
}
if stub.lastAuth != "Bearer local" {
t.Errorf("Authorization = %q,期望 \"Bearer local\"", stub.lastAuth)
}
}
// TestAudioRouteTestLocalWinsOverProviderName 是「回环地址免密钥」这条规则
// 在通路测试里也生效的证据 —— 也正是三份 requiresAPIKey 副本会走偏的那一格。
//
// 场景:内网有人用 OpenAI 兼容的壳把本地 ASR 包了一层,provider 于是写成 openai,
// base_url 仍是 127.0.0.1。判据若看 provider 名(旧副本就是这么写的),
// 就会报「API Key 未配置」;而真实调用根本不需要密钥。
// 判据应当是「流量去哪」——回环地址就是不需要。
func TestAudioRouteTestLocalWinsOverProviderName(t *testing.T) {
stub := newAudioStub(t)
route := audioRoute(stub)
route.Provider = "openai" // 名字像云端,地址是回环
res := Do(route, &TestOptions{})
if !res.Items[0].Ok {
t.Fatalf("回环地址的路由不该因为 provider 名像云端就被要求密钥:%s", res.Items[0].Error)
}
if stub.audioCalls != 1 {
t.Errorf("转写终点被打了 %d 次,期望 1 次", stub.audioCalls)
}
}
// TestAudioRouteTestTreatsEmptyTextAsSuccess 静音样本转出空文本是**预期**,不是失败。
//
// 判成功只看「200 + 合法 JSON」。把空文本当失败,一条完全正常的本地 whisper
// 路由(静音就返回 text="")会被测红,然后有人去改配置。
func TestAudioRouteTestTreatsEmptyTextAsSuccess(t *testing.T) {
stub := newAudioStub(t)
res := Do(audioRoute(stub), &TestOptions{})
item := res.Items[0]
if !item.Ok {
t.Fatalf("空文本被当成了失败:%s", item.Error)
}
if !strings.Contains(item.OutputPreview, "静音样本") {
t.Errorf("产物预览应说明空文本是静音所致,实际:%q", item.OutputPreview)
}
}
// TestAudioRouteTestExplainsClientRejection 4xx 的报错要说清「可能是静音被拒」。
// 否则读的人只会看到「返回 400」,然后把一条好路由当成坏的。
func TestAudioRouteTestExplainsClientRejection(t *testing.T) {
stub := newAudioStub(t)
stub.audioStatus = http.StatusBadRequest
res := Do(audioRoute(stub), &TestOptions{})
item := res.Items[0]
if item.Ok {
t.Fatal("400 不该算通过")
}
if !strings.Contains(item.Error, "静音") || !strings.Contains(item.Error, "不代表路由不可用") {
t.Errorf("4xx 的报错没解释静音这一层:%q", item.Error)
}
if item.StatusCode != http.StatusBadRequest {
t.Errorf("StatusCode = %d,期望 400", item.StatusCode)
}
}
// TestSilentWAVIsWellFormed WAV 头写错的话服务端多半直接 400,
// 上面那些用例会以「4xx」的形式红,而不会告诉你「样本本身不合法」。
func TestSilentWAVIsWellFormed(t *testing.T) {
const dataBytes = 16000 * 2 // 1 秒 × 16kHz × 16bit
data := silentWAV(1000)
if len(data) != 44+dataBytes {
t.Errorf("长度 = %d,期望 %d(44 字节头 + 1 秒采样)", len(data), 44+dataBytes)
}
if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" || string(data[36:40]) != "data" {
t.Fatalf("RIFF/WAVE/data 标识不对:%q %q %q", data[0:4], data[8:12], data[36:40])
}
le := func(off int, n int) uint32 {
var v uint32
for i := 0; i < n; i++ {
v |= uint32(data[off+i]) << (8 * i)
}
return v
}
if got := le(24, 4); got != 16000 {
t.Errorf("采样率 = %d,期望 16000", got)
}
if got := le(22, 2); got != 1 {
t.Errorf("声道数 = %d,期望 1(单声道)", got)
}
if got := le(34, 2); got != 16 {
t.Errorf("位深 = %d,期望 16", got)
}
if got := le(40, 4); got != dataBytes {
t.Errorf("data 块长度 = %d,期望 %d", got, dataBytes)
}
if got := le(4, 4); got != uint32(36+dataBytes) {
t.Errorf("RIFF 长度 = %d,期望 %d", got, 36+dataBytes)
}
// 采样必须全零,否则「静音样本」这个名字就是假的。
for i, b := range data[44:] {
if b != 0 {
t.Fatalf("第 %d 个采样字节非零 —— 这不是静音样本", i)
}
}
}
@@ -11,9 +11,11 @@ package routetest
import (
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"sort"
"strings"
@@ -114,7 +116,10 @@ func testOne(route *config.RouteConfig, model string, prompt, size string) Resul
item.Error = "base_url 未配置"
return item
}
if requiresAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
// 判据与健康探测、真实调用共用 config 的那一份:本包原先自己抄了一遍。
// 两份在今天的配置上恰好同结论,但只有 config 那份知道「回环地址免密钥」;
// 抄出来的第二份一旦漏跟,症状是「通路测试说缺密钥,真实调用却是通的」。
if config.RequiresRouteAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
item.Error = "API Key 未配置"
return item
}
@@ -127,6 +132,11 @@ func testOne(route *config.RouteConfig, model string, prompt, size string) Resul
testVideo(route, model, prompt, &item)
case "embed":
testEmbed(route, model, prompt, &item)
case "audio":
// 必须单独一支:audio 的终点是 /audio/transcriptions、请求体是 multipart,
// 落到 default 会把一个 chat 请求打到 ASR 服务上,报回来的错
// (模型不存在 / 参数不对)与真实原因(走错接口)毫无关系。
testAudio(route, model, prompt, &item)
default:
testChat(route, model, prompt, &item)
}
@@ -374,6 +384,169 @@ func testVideo(route *config.RouteConfig, model, prompt string, item *ResultItem
item.Error = "响应缺少 url / b64_json"
}
// testAudio 语音转写类单模型测试。
//
// 拿一段**程序生成的静音 WAV** 去打真实的 /audio/transcriptions,验的是
// 「这条路走不走得通」:终点对不对、密钥收不收、multipart 字段名对不对、
// 返回的是不是转写那套结构。**不验转写质量** —— 静音样本本来就没什么可转的,
// 把「没转出字」当失败会让一条好路由被测红。
//
// 所以判成功的标准只有一条:HTTP 200 且响应是合法 JSON。摘要里会写明
// 「静音样本」,避免被读成「转写质量已验证」。
//
// 静音是现算的,不内嵌音频文件:P06.11 不允许引入第三方受限素材,
// 而原型机的真实录音属于用户数据,更不能编进二进制。
func testAudio(route *config.RouteConfig, model, prompt string, item *ResultItem) {
// audio 的终点在配置里(route.Endpoint),不像 chat/embed/image 是固定路径。
endpoint := strings.TrimSpace(route.Endpoint)
if endpoint == "" {
endpoint = "/audio/transcriptions"
}
sample := silentWAV(1000)
item.RawRequest = fmt.Sprintf(
`{"endpoint":%q,"model":%q,"file":"silence-1s.wav","bytes":%d,"note":"程序生成的静音样本,仅验证接口连通性"}`,
endpoint, model, len(sample))
var out struct {
Text string `json:"text"`
Segments []struct {
Speaker string `json:"speaker"`
Text string `json:"text"`
} `json:"segments"`
Duration float64 `json:"duration"`
Model string `json:"model"`
Error any `json:"error"`
}
code, rawRes, err := httpPostFile(route, endpoint, "silence-1s.wav", sample, map[string]string{
"model": model,
}, &out)
item.StatusCode = code
item.RawResponse = truncate(rawRes, 4000)
if err != nil {
// 4xx 要单独说清楚:静音被拒是这一类接口的常见反应,不能让人以为
// 路由坏了。真正的转写能力得用一份真实录音走一次语音转写技能才算数。
if code >= 400 && code < 500 {
item.Error = fmt.Sprintf("%v(样本是 1 秒静音,若服务方拒收无语音音频,此结果不代表路由不可用;"+
"请用一份真实录音跑一次「语音转写」技能确认)", err)
return
}
item.Error = err.Error()
return
}
item.ModelEcho = firstNonEmpty(out.Model, model)
item.Summary = fmt.Sprintf("转写接口应答正常(1 秒静音样本,返回 %d 段,未检验转写质量)", len(out.Segments))
if strings.TrimSpace(out.Text) != "" {
item.OutputPreview = truncate(out.Text, 2000)
} else {
item.OutputPreview = "(静音样本,文本为空属预期)"
}
}
// httpPostFile 发一次 multipart/form-data 的 POST。
//
// 鉴权头与真实转写**逐字对齐**:没有密钥时也要发 `Bearer local`。
// 本地 serve.py 不校验密钥的值,但收不到这个头就回 401 —— 这里若图省事跳过,
// 本地 ASR 在通路测试里会稳定报 401,而真实转写明明是通的。
func httpPostFile(route *config.RouteConfig, path, filename string, content []byte, fields map[string]string, out any) (int, string, error) {
buf := bytes.NewBuffer(nil)
w := multipart.NewWriter(buf)
for _, key := range sortedKeys(fields) {
fw, err := w.CreateFormField(key)
if err != nil {
return 0, "", fmt.Errorf("构建请求失败: %w", err)
}
if _, err := fw.Write([]byte(fields[key])); err != nil {
return 0, "", fmt.Errorf("构建请求失败: %w", err)
}
}
fw, err := w.CreateFormFile("file", filename)
if err != nil {
return 0, "", fmt.Errorf("构建请求失败: %w", err)
}
if _, err := fw.Write(content); err != nil {
return 0, "", fmt.Errorf("写入样本失败: %w", err)
}
if err := w.Close(); err != nil {
return 0, "", fmt.Errorf("构建请求失败: %w", err)
}
fullURL := strings.TrimRight(route.BaseURL, "/") + path
req, err := http.NewRequest(http.MethodPost, fullURL, buf)
if err != nil {
return 0, "", fmt.Errorf("构造请求失败: %w", err)
}
req.Header.Set("Content-Type", w.FormDataContentType())
authKey := route.APIKey
if strings.TrimSpace(authKey) == "" {
authKey = "local"
}
req.Header.Set("Authorization", "Bearer "+authKey)
client := &http.Client{Timeout: defaultTestTimeout}
resp, err := client.Do(req)
if err != nil {
return 0, "", fmt.Errorf("服务不可达: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, "", fmt.Errorf("读取响应失败: %w", err)
}
rawStr := string(data)
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, rawStr, fmt.Errorf("返回 %d: %s", resp.StatusCode, truncate(rawStr, 200))
}
if len(data) > 0 && out != nil {
if err := json.Unmarshal(data, out); err != nil {
return resp.StatusCode, rawStr, fmt.Errorf("响应解析失败: %w", err)
}
}
return resp.StatusCode, rawStr, nil
}
// silentWAV 生成一段 ms 毫秒的 16kHz 单声道 16bit 静音 WAV。
// 标准 44 字节 RIFF 头 + 全零采样,任何 ASR 服务都认这个容器格式。
func silentWAV(ms int) []byte {
const sampleRate = 16000
samples := sampleRate * ms / 1000
dataSize := samples * 2
var buf bytes.Buffer
write := func(v any) { _ = binary.Write(&buf, binary.LittleEndian, v) }
buf.WriteString("RIFF")
write(uint32(36 + dataSize))
buf.WriteString("WAVEfmt ")
write(uint32(16)) // fmt chunk 长度
write(uint16(1)) // PCM
write(uint16(1)) // 单声道
write(uint32(sampleRate)) // 采样率
write(uint32(sampleRate * 2)) // 字节率 = 采样率 × 声道 × 位深/8
write(uint16(2)) // 块对齐 = 声道 × 位深/8
write(uint16(16)) // 位深
buf.WriteString("data")
write(uint32(dataSize))
buf.Write(make([]byte, dataSize))
return buf.Bytes()
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// DiscoverModels 拉取 provider 端 /models 获取可用模型列表(自动发现)。
// 返回按字母排序的模型 ID 列表;不可达或失败时返回 nil + error。
func DiscoverModels(route *config.RouteConfig) ([]string, error) {
@@ -404,20 +577,6 @@ func MergeCandidates(known, discovered []string) []string {
return out
}
// requiresAPIKey 判断该路由是否必须携带 API Key(只有云端 provider 才强制)。
// 本地服务(ollama / llamacpp 等)无需 key 也应能完成测试。
func requiresAPIKey(route *config.RouteConfig) bool {
if route == nil {
return false
}
baseURL := strings.ToLower(strings.TrimSpace(route.BaseURL))
if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
return true
}
provider := strings.ToLower(strings.TrimSpace(route.Provider))
return provider == "openrouter" || provider == "openai"
}
func truncate(s string, n int) string {
s = strings.TrimSpace(s)
if n <= 0 || len(s) <= n {
@@ -0,0 +1,79 @@
package ai
import (
"context"
"encoding/json"
"fmt"
"strings"
"eai_agentplatform/backend/internal/search"
)
// webSearchTool 让 LLM 通过 SearXNG 搜索实时信息。
type webSearchTool struct {
client *search.Client
}
// NewWebSearchTool 创建网页搜索工具。client 可为 nil(使用全局默认客户端)。
func NewWebSearchTool(client *search.Client) Tool {
if client == nil {
client = search.GetClient()
}
return &webSearchTool{client: client}
}
// args webSearchTool 的参数结构。
type args struct {
Query string `json:"query"`
}
// Schema 返回 OpenAI 兼容的 function schema。
func (t *webSearchTool) Schema() ToolSchema {
var s ToolSchema
s.Type = "function"
s.Function.Name = "web_search"
s.Function.Description = "搜索互联网获取最新的文章、数据、报告与案例。当你需要引用最新市场数据、行业案例、政策信息或验证事实时调用本工具。返回若干条带标题、链接与摘要的结果。"
s.Function.Parameters = map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
"type": "string",
"description": "搜索关键词,尽量具体,可含行业、年份、数据点等,例如「2025年中国外骨骼机器人市场规模」",
},
},
"required": []string{"query"},
}
return s
}
// Execute 执行搜索并把结果格式化为 LLM 易读的文本。
func (t *webSearchTool) Execute(ctx context.Context, rawArgs json.RawMessage) (string, error) {
var a args
if err := json.Unmarshal(rawArgs, &a); err != nil {
return "", fmt.Errorf("解析 web_search 参数失败: %w", err)
}
a.Query = strings.TrimSpace(a.Query)
if a.Query == "" {
return "", fmt.Errorf("web_search 缺少 query 参数")
}
results, err := t.client.Search(a.Query)
if err != nil {
return "", err
}
if len(results) == 0 {
return fmt.Sprintf("搜索「%s」无结果。", a.Query), nil
}
var b strings.Builder
for i, r := range results {
b.WriteString(fmt.Sprintf("%d. %s\n", i+1, r.Title))
if r.URL != "" {
b.WriteString(fmt.Sprintf(" 链接: %s\n", r.URL))
}
if r.Content != "" {
b.WriteString(fmt.Sprintf(" 摘要: %s\n", r.Content))
}
}
return strings.TrimSpace(b.String()), nil
}