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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user