大模型可用性测试

本文档面向实施人员,用于大模型接入 Data Agent 前,快速判断其是否满足系统要求。测试重点不是“模型能不能聊天”,而是“模型能否稳定完成 Data Agent 所需的工具调用、问题标准化和向量检索”。

适用范围

  • 主模型(Chat Completion)
  • 向量模型(Embedding)
  • 通过私有网关暴露的模型服务

如果你使用的是兼容 OpenAI 风格的 API 网关,可直接参考本文中的 curl 示例;如果接口格式不同,请按相同测试目标自行换成对应协议。

验收通过标准

一套大模型要满足 Data Agent 要求,至少需要同时通过以下检查:

  • 主模型支持 Tool Call(Function Calling),并能稳定输出可解析的参数
  • 主模型能够把用户的模糊提问改写成更明确的问题,且不擅自改变业务含义
  • 主模型中文理解正常,响应速度可接受
  • Embedding 模型能稳定返回固定维度的向量,且语义相近文本的相似度高于无关文本
  • 在 Data Agent 中,Nora 能正常调用 Data Query(Alisa)等工具,而不是只返回一段自然语言

测试前准备

  • 主模型的 baseURLmodelapiKey
  • Embedding 模型的 baseURLmodelapiKey
  • curl、Postman 或 APIFox 中任意一种调试工具
  • 一个可登录的 Data Agent 环境
  • 一个可用的数据源和基础语义模型;如果还没有业务数据,可先使用快速开始中的示例数据体验流程
注意

如果你的模型服务部署在内网,而 Data Agent 运行在 Docker 容器内,请先确认容器到模型网关的网络可达。很多“模型不可用”问题,本质上是网络、证书或鉴权配置错误。

一、主模型验收测试

以下curl命令和js脚本均在容器内终端执行。

简单测试

curl -i -sS "$BASE_URL/chat/completions" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{
    "model": "'"$MODEL"'",
    "messages": [
      { "role": "system", "content": "You are Nora." },
      { "role": "user", "content": "请调用工具 query_metric,参数 metric=销售额。" }
    ],
    "temperature": 0.1,
    "stream": false,
    "tools": [
      {
        "type": "function",
        "function": {
          "name": "query_metric",
          "description": "Query a metric",
          "parameters": {
            "type": "object",
            "properties": {
              "metric": {
                "type": "string",
                "description": "metric name"
              }
            },
            "required": ["metric"]
          }
        }
      }
    ]
  }'

如果状态码为 200,且响应中包含 query_metric 工具调用,说明模型支持 Tool Call(Function Calling),并能稳定输出可解析的参数。

进阶测试

如果上述简单测试通过了,但是Data Agent仍不可用,可运行下面的js脚本,确认模型是否支持 Data Agent 所需的工具调用。

// 调用脚本的命令
// node test-nora-agent.js \
//   --api-key 'xxx' \
//   --base-url 'http://25.42.245.59/xlm-gateway-yuuvsm/sfm-api-gateway/gateway/compatible-mode/v1' \
//   --model 'rsv-fnjhvprfssmhnjjgdzoky-pozyhc'

const { ChatOpenAI } = require('@langchain/openai');
const { tool } = require('langchain');
const { z } = require('zod');

const DEFAULT_SYSTEM_PROMPT = [
  '你叫 Nora,你是一个专业的智能数据分析助手。',
  '如果问题属于数据查询或数据分析,你可以调用工具。',
  '在调用工具前不要编造任何数字。',
].join('\n');

const DEFAULT_MESSAGE = '请简单自我介绍,并说明你在需要时可以调用工具。';

function parseArgs(argv) {
  const args = new Map();

  for (let i = 0; i < argv.length; i += 1) {
    const current = argv[i];
    if (!current.startsWith('--')) {
      continue;
    }
    const key = current.slice(2);
    const next = argv[i + 1];
    if (!next || next.startsWith('--')) {
      args.set(key, 'true');
      continue;
    }
    args.set(key, next);
    i += 1;
  }

  if (args.has('help')) {
    printHelp();
    process.exit(0);
  }

  const apiKey =
    args.get('api-key') ||
    process.env.NORA_TEST_API_KEY ||
    process.env.OPENAI_API_KEY;
  const baseURL =
    args.get('base-url') ||
    process.env.NORA_TEST_BASE_URL ||
    process.env.OPENAI_BASE_URL;
  const model =
    args.get('model') ||
    process.env.NORA_TEST_MODEL ||
    process.env.OPENAI_MODEL;

  if (!apiKey || !baseURL || !model) {
    printHelp();
    throw new Error('Missing required args: --api-key, --base-url, --model');
  }

  const only = args.get('only');
  if (
    only &&
    !['basic-invoke', 'invoke-with-tools', 'stream-with-tools'].includes(only)
  ) {
    throw new Error(`Invalid --only value: ${only}`);
  }

  return {
    apiKey,
    baseURL,
    model,
    message:
      args.get('message') || process.env.NORA_TEST_MESSAGE || DEFAULT_MESSAGE,
    system:
      args.get('system') ||
      process.env.NORA_TEST_SYSTEM ||
      DEFAULT_SYSTEM_PROMPT,
    timeout: Number(
      args.get('timeout') || process.env.NORA_TEST_TIMEOUT || 60000,
    ),
    maxRetries: Number(
      args.get('max-retries') || process.env.NORA_TEST_MAX_RETRIES || 1,
    ),
    temperature: Number(
      args.get('temperature') || process.env.NORA_TEST_TEMPERATURE || 0.1,
    ),
    topP: Number(args.get('top-p') || process.env.NORA_TEST_TOP_P || 1),
    presencePenalty: Number(
      args.get('presence-penalty') ||
        process.env.NORA_TEST_PRESENCE_PENALTY ||
        0,
    ),
    frequencyPenalty: Number(
      args.get('frequency-penalty') ||
        process.env.NORA_TEST_FREQUENCY_PENALTY ||
        0,
    ),
    responseFormat:
      args.get('response-format') ||
      process.env.NORA_TEST_RESPONSE_FORMAT ||
      'text',
    only,
  };
}

function printHelp() {
  const lines = [
    'Usage:',
    '  node test-nora-agent.js --api-key <key> --base-url <url> --model <model> [options]',
    '',
    'Options:',
    '  --message <text>             User message for the test',
    '  --system <text>              System prompt for the test',
    '  --timeout <ms>               Request timeout, default 60000',
    '  --max-retries <n>            Client retries, default 1',
    '  --temperature <n>            Default 0.1',
    '  --top-p <n>                  Default 1',
    '  --presence-penalty <n>       Default 0',
    '  --frequency-penalty <n>      Default 0',
    '  --response-format <type>     text | json_object | json_schema',
    '  --only <test>                basic-invoke | invoke-with-tools | stream-with-tools',
    '',
    'Env fallbacks:',
    '  NORA_TEST_API_KEY / OPENAI_API_KEY',
    '  NORA_TEST_BASE_URL / OPENAI_BASE_URL',
    '  NORA_TEST_MODEL / OPENAI_MODEL',
  ];
  console.log(lines.join('\n'));
}

function createModel(config) {
  return new ChatOpenAI({
    apiKey: config.apiKey,
    model: config.model,
    timeout: config.timeout,
    maxRetries: config.maxRetries,
    temperature: config.temperature,
    topP: config.topP,
    presencePenalty: config.presencePenalty,
    frequencyPenalty: config.frequencyPenalty,
    modelKwargs: {
      response_format: {
        type: config.responseFormat,
      },
    },
    configuration: {
      baseURL: config.baseURL,
    },
  });
}

function createNoraLikeTools() {
  return [
    tool(
      async ({ query }) =>
        JSON.stringify({
          status: 'success',
          message: `Stub yiask-askAlisa executed with query: ${query}`,
        }),
      {
        name: 'yiask-askAlisa',
        description:
          'Nora uses this tool to delegate data query tasks to Alisa.',
        schema: z.object({
          query: z.string().describe('The user query to send to Alisa'),
        }),
      },
    ),
    tool(
      async ({ metric, dimension }) =>
        JSON.stringify({
          status: 'success',
          message: `Stub yiask-attribution executed for metric=${metric}, dimension=${dimension || ''}`,
        }),
      {
        name: 'yiask-attribution',
        description: 'Nora uses this tool for attribution analysis.',
        schema: z.object({
          metric: z.string().describe('Metric name'),
          dimension: z.string().optional().describe('Optional dimension name'),
        }),
      },
    ),
    tool(
      async ({ metric }) =>
        JSON.stringify({
          status: 'success',
          views: [`Stub metric view for ${metric}`],
        }),
      {
        name: 'yiask_sage-getMetricViews',
        description: 'Fetch metric views for a metric.',
        schema: z.object({
          metric: z.string().describe('Metric name'),
        }),
      },
    ),
    tool(
      async ({ metric }) =>
        JSON.stringify({
          status: 'success',
          dimensions: [`Stub dimension for ${metric}`],
        }),
      {
        name: 'yiask_sage-getMetricDimensions',
        description: 'Fetch metric dimensions for a metric.',
        schema: z.object({
          metric: z.string().describe('Metric name'),
        }),
      },
    ),
    tool(
      async ({ cron, task }) =>
        JSON.stringify({
          status: 'success',
          message: `Stub yiask-createSchedule executed with cron=${cron}, task=${task}`,
        }),
      {
        name: 'yiask-createSchedule',
        description: 'Create a schedule task.',
        schema: z.object({
          cron: z.string().describe('Cron expression'),
          task: z.string().describe('Task description'),
        }),
      },
    ),
  ];
}

function buildMessages(config) {
  return [
    { role: 'system', content: config.system },
    { role: 'user', content: config.message },
  ];
}

function formatError(error) {
  const lines = [
    `message=${error && error.message ? error.message : 'unknown error'}`,
    `status=${error && error.status !== undefined ? error.status : 'n/a'}`,
    `request_id=${error && (error.requestID || error.request_id) ? error.requestID || error.request_id : 'n/a'}`,
  ];

  if (error && error.headers) {
    lines.push(`headers=${JSON.stringify(error.headers)}`);
  }

  if (error && error.error) {
    lines.push(`body=${JSON.stringify(error.error)}`);
  } else if (error && error.response && error.response.data) {
    lines.push(`body=${JSON.stringify(error.response.data)}`);
  }

  return lines.join('\n');
}

function extractText(content) {
  if (typeof content === 'string') {
    return content;
  }

  if (Array.isArray(content)) {
    return content
      .map((item) => {
        if (typeof item === 'string') {
          return item;
        }
        if (item && typeof item === 'object' && 'text' in item) {
          return String(item.text || '');
        }
        return JSON.stringify(item);
      })
      .join('');
  }

  if (content == null) {
    return '';
  }

  return JSON.stringify(content);
}

async function runBasicInvoke(config) {
  const model = createModel(config);
  const messages = buildMessages(config);

  try {
    const response = await model.invoke(messages);
    return {
      name: 'basic-invoke',
      ok: true,
      summary: 'basic invoke succeeded',
      details: extractText(response.content).slice(0, 500),
    };
  } catch (error) {
    return {
      name: 'basic-invoke',
      ok: false,
      summary: 'basic invoke failed',
      details: formatError(error),
    };
  }
}

async function runInvokeWithTools(config) {
  const model = createModel(config);
  const messages = buildMessages(config);
  const tools = createNoraLikeTools();

  try {
    const chain = model.bindTools(tools);
    const response = await chain.invoke(messages);
    const toolCalls = Array.isArray(response.tool_calls)
      ? response.tool_calls.length
      : 0;
    return {
      name: 'invoke-with-tools',
      ok: true,
      summary: `invoke with tools succeeded, tool_calls=${toolCalls}`,
      details: extractText(response.content).slice(0, 500),
    };
  } catch (error) {
    return {
      name: 'invoke-with-tools',
      ok: false,
      summary: 'invoke with tools failed',
      details: formatError(error),
    };
  }
}

async function runStreamWithTools(config) {
  const model = createModel(config);
  const messages = buildMessages(config);
  const tools = createNoraLikeTools();

  try {
    const chain = model.bindTools(tools);
    const stream = await chain.streamEvents(messages, {
      version: 'v2',
      signal: new AbortController().signal,
    });

    let chunks = 0;
    let content = '';

    for await (const event of stream) {
      if (event.event !== 'on_chat_model_stream') {
        continue;
      }
      chunks += 1;
      const chunkContent = extractText(
        event.data && event.data.chunk ? event.data.chunk.content : '',
      );
      content += chunkContent;
    }

    return {
      name: 'stream-with-tools',
      ok: true,
      summary: `stream with tools succeeded, chunks=${chunks}`,
      details: content.slice(0, 500),
    };
  } catch (error) {
    return {
      name: 'stream-with-tools',
      ok: false,
      summary: 'stream with tools failed',
      details: formatError(error),
    };
  }
}

async function main() {
  const config = parseArgs(process.argv.slice(2));
  const tests = [];

  if (!config.only || config.only === 'basic-invoke') {
    tests.push(() => runBasicInvoke(config));
  }
  if (!config.only || config.only === 'invoke-with-tools') {
    tests.push(() => runInvokeWithTools(config));
  }
  if (!config.only || config.only === 'stream-with-tools') {
    tests.push(() => runStreamWithTools(config));
  }

  console.log('Nora agent connectivity test');
  console.log(`baseURL=${config.baseURL}`);
  console.log(`model=${config.model}`);
  console.log(`timeout=${config.timeout}`);
  console.log(`maxRetries=${config.maxRetries}`);
  console.log(`responseFormat=${config.responseFormat}`);
  console.log('');

  const results = [];
  for (const test of tests) {
    const result = await test();
    results.push(result);
    console.log(
      `[${result.ok ? 'PASS' : 'FAIL'}] ${result.name}: ${result.summary}`,
    );
    if (result.details) {
      console.log(result.details);
    }
    console.log('');
  }

  if (results.some((result) => !result.ok)) {
    process.exitCode = 1;
  }
}

main().catch((error) => {
  console.error('Fatal error');
  console.error(formatError(error));
  process.exit(1);
});

通过标准:

  • 返回 HTTP 200
  • 返回体中有非空结果
  • 没有鉴权失败、模型不存在、上下文格式错误等异常

如果这一步失败,不要继续排查 Data Agent,先修复模型服务本身。

二、Embedding 模型验收测试

本节介绍各个 Embedding Provider 在执行测试时,实际发往上游服务的 HTTP 请求对照。

当前仓库支持的 Embedding Provider 包括:

  • openai
  • openai-completions
  • google-genai
  • dashscope
  • ollama
  • bge

1. 总览

provider实际上游接口鉴权方式
openaiPOST {baseURL}/embeddingsAuthorization: Bearer <apiKey>
openai-completionsPOST {baseURL}/embeddingsAuthorization: Bearer <apiKey>
dashscopePOST {baseURL}/embeddingsAuthorization: Bearer <apiKey>
google-genaiPOST {baseURL}/v1beta/models/{model}:batchEmbedContentsx-goog-api-key: <apiKey>
ollamaPOST {baseURL}/api/embed
bgePOST {baseURL}/embeddingsAuthorization: Bearer <apiKey>

说明:

  • openaiopenai-completionsdashscope 在当前实现里都走 OpenAI 风格的 Embeddings 接口。

2. 各 Provider 对应的 curl

OpenAI

默认 baseURL

https://api.openai.com/v1

默认测试模型示例:

text-embedding-3-small

对应 curl

curl --location 'https://api.openai.com/v1/embeddings' \
  --header 'Authorization: Bearer YOUR_OPENAI_API_KEY' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "model": "text-embedding-3-small",
    "input": ["test"],
    "encoding_format": "base64"
  }'

说明:

  • input 是数组,因为代码走的是 embedDocuments(['test'])
  • 当前 SDK 在未显式指定 encoding_format 时,会默认发送 base64

OpenAI(completions)

虽然 Provider 名称不同,但当前 Embedding 实现与 openai 相同,底层请求也一致,只是配置入口不同。

curl --location 'https://api.openai.com/v1/embeddings' \
  --header 'Authorization: Bearer YOUR_OPENAI_API_KEY' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "model": "text-embedding-3-small",
    "input": ["test"],
    "encoding_format": "base64"
  }'

DashScope

当前实现中,DashScope 也是通过 OpenAIEmbeddings 调用兼容接口,因此请求形态与 OpenAI 类似。

默认 baseURL

https://dashscope.aliyuncs.com/compatible-mode/v1

默认测试模型示例:

text-embedding-v4

对应 curl

curl --location 'https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings' \
  --header 'Authorization: Bearer YOUR_DASHSCOPE_API_KEY' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "model": "text-embedding-v4",
    "input": ["test"],
    "encoding_format": "base64"
  }'

Google Generative AI

默认 baseURL

https://generativelanguage.googleapis.com

默认 API 版本:

v1beta

默认测试模型示例:

gemini-embedding-001

因为当前代码调用的是 embedDocuments(['test']),所以实际会走:

POST https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:batchEmbedContents

对应 curl

curl --location 'https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:batchEmbedContents' \
  --header 'x-goog-api-key: YOUR_GOOGLE_API_KEY' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "requests": [
      {
        "model": "models/gemini-embedding-001",
        "content": {
          "role": "user",
          "parts": [
            {
              "text": "test"
            }
          ]
        }
      }
    ]
  }'

说明:

  • @langchain/google-genai 会先把 gemini-embedding-001 标准化成 models/gemini-embedding-001
  • 请求头使用 x-goog-api-key,不是 Authorization: Bearer ...

Ollama

默认 baseURL

http://localhost:11434

默认测试模型示例:

nomic-embed-text

因为当前代码调用的是 embedDocuments(['test']),所以实际会走:

POST http://localhost:11434/api/embed

对应 curl

curl --location 'http://localhost:11434/api/embed' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "model": "nomic-embed-text",
    "input": ["test"],
    "truncate": false
  }'

说明:

  • 当前实现不会给 Ollama 传鉴权头。
  • 这里不是 /api/embeddings,而是 /api/embed
  • truncate 在当前 LangChain 实现中的默认值是 false

如果要先查看本地有哪些模型:

curl --location 'http://localhost:11434/api/tags'

BGE

POST {baseURL}/embeddings

请求头:

Content-Type: application/json
Authorization: Bearer <apiKey>

请求体:

{
  "model": "bge-m3",
  "input": ["test"]
}

对应 curl

curl --location 'http://localhost:8000/embeddings' \
  --header 'Authorization: Bearer YOUR_BGE_API_KEY' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "model": "bge-m3",
    "input": ["test"]
  }'

说明:

  • 当前实现中,即使 BGE 服务本身不校验 token,YiAsk 侧也要求配置 apiKey
  • baseURL 在当前实现里没有默认值,需要手动配置。

3. 映射关系

  • openai -> POST /v1/embeddings
  • openai-completions -> POST /v1/embeddings
  • dashscope -> POST /compatible-mode/v1/embeddings
  • google-genai -> POST /v1beta/models/{model}:batchEmbedContents
  • ollama -> POST /api/embed
  • bge -> POST /embeddings

三、常见不通过现象

1. 模型能聊天,但不会调工具

这是最常见的不通过情况。此类模型即使看起来“回答很聪明”,也不满足 Data Agent 要求。

2. 工具参数不稳定

常见表现包括:

  • JSON 不完整
  • 字段名随机变化
  • 参数里夹杂解释性自然语言

这种情况通常说明模型的指令遵循能力不足,或网关对工具调用协议支持不完整。

3. 只支持推理输出,不适合生产问答

某些推理类模型会输出很长的中间思考内容,或者在工具调用场景下表现不稳定。对于需要稳定结构化输出的场景,优先选择非推理类模型。

4. Embedding 维度或效果异常

如果同一个模型返回的向量维度不一致,或相近中文词语的相似度排序明显错误,通常不建议继续接入。

5. 网关兼容但协议不完整

有些私有化网关宣称“兼容 OpenAI”,但实际上只支持基本聊天,不支持 toolstool_choice 或标准返回格式。这种情况下也会导致接入失败。

四、建议的验收记录

实施过程中,建议记录一次正式验收结果:

检查项结果备注
主模型接口连通性通过 / 不通过
主模型中文改写能力通过 / 不通过
主模型 Tool Call 能力通过 / 不通过
主模型稳定性通过 / 不通过
Embedding 接口连通性通过 / 不通过
Embedding 基础语义效果通过 / 不通过
Data Agent 实际问答链路通过 / 不通过

只有当以上检查都通过时,才建议将该模型用于正式实施或生产环境。