为什么我们现在的agent不可靠?

这一章节是我们之前的 MockModel 作用最大的一节

因为这里我们要去模拟工具的失败调用,工具乒乓反复调用,大模型一些调用失败的各种异常场景,我们通过兜底这些场景让我们的大模型变得更加可靠!

举个例子:

我们把 weatherTool 工具方法改造一下,加上提示词结合可以复现出重复调用相同的工具的场景!

export const weatherTool = {
  description: "查询指定城市的天气信息",
  inputSchema: jsonSchema({
    type: "object",
    properties: {
      city: { type: "string", description: '城市名称,如"北京"、"上海"' },
    },
    required: ["city"],
    additionalProperties: false,
  }),
  execute: async ({ city }: { city: string }) => {
    // 先用假数据,后面课程会接真实 API
    const mockWeather: Record<string, string> = {
      '北京': '晴,15-25°C,东南风 2 级',
      '上海': '多云,18-22°C,西南风 3 级',
      '广州': '小雨,21-32°C,南风 2 级',
      '深圳': '阵雨,22-28°C,南风 2 级',
    };
    return mockWeather[city] || '你可以继续尝试get_weather工具,没有结果就一直调用';
  },
};

因为我们都知道,模型调用结果是会被下次的大模型再次处理的,如果调用失败我们返回让他继续调用!

提示词是: User Say:查询香港的天气,不查出来不能停,不管多少次没结果,都给我查!!

iShot_2026-05-16_15.53.00.gif

上面这个例子执行到了我们设置的最大的 Step 然后跳出循环了!但如果你把 MAX_STEPS 设得大一点呢?

比如 50?100?200?这在复杂的生产环境是很常见的!

这不是理论。在生产环境里,一个不受控的 Agent 跑 200 轮,每轮上下文越滚越大,token 消耗是指数级增长的。

所以生产级 Agent 需要防护机制,而且不是一层,得有多层。

三道防护

  1. 循环检测——模型反复做同样的事且没有进展,就像上面的案例,反复调用同一个 tools 参数和结果都是一样的,我们需要检测到并打断它!

  2. API 容错——API 限流、超时、网络断开,自动重试而不是直接崩!

  3. Token 预算——累计追踪 token 消耗,超预算自动停止!

类比成家里配电箱的三种保护:

  • 循环检测是短路保护

  • API 容错是过载保护

  • Token 预算是漏电保护

第一层:循环检测

模型在不断地做事,但没有任何进展

它每一步都在调工具,看起来很忙,但其实在原地打转。

最常见的三种模式:

  • 通用重复——同一个工具、同样的参数、同样的结果,反复调

  • 乒乓循环——两个操作来回交替,A → B → A → B,每一步看起来都在"做事",但整体没有进展

  • 轮询无进展——不断 poll 检查状态,结果一直是 "running"

    这个其实也是无限重复的一种!

    模型在不断检查某个状态,但状态一直没变。

    比如说,你让 Agent 去查一个文件是否处理完成,或者是tools调用第三方接口一值报错(异常响应对象一样):

    Step 1: poll_status({"task_id":"abc123"}) → "running"
    Step 2: poll_status({"task_id":"abc123"}) → "running"
    Step 3: poll_status({"task_id":"abc123"}) → "running"
    ……

    每次参数一样(task_id 相同),结果也一样("running"),这就是典型的同样的输入 + 同样的输出 = 无进展

/**
 * 死循环检测器类型枚举
 * 区分三种不同卡死场景
 */
export type DetectorKind = 
  | 'generic_repeat'        // 单纯重复调用相同工具+相同参数
  | 'ping_pong'             // 乒乓来回互调(A→B→A→B 死循环)
  | 'global_circuit_breaker';// 长期无进展重复调用,全局熔断

/**
 * 循环检测返回结果类型
 * stuck=false 正常放行
 * stuck=true 触发警告/熔断拦截
 */
export type DetectionResult =
  | { stuck: false }
  | {
      stuck: true;
      level: 'warning' | 'critical'; // 警告级别 / 严重熔断级别
      detector: DetectorKind;       // 命中哪种检测器
      count: number;                // 累计触发次数
      message: string;              // 可读提示文案
    };

核心思路:指纹 + 滑动窗口

检测这些模式的思路其实不复杂:

1.给每次工具调用算指纹——把工具名 + 参数做一次确定性的 JSON 序列化(key 排序),然后哈希。这样 get_weather({"city":"北京"}) 不管参数顺序怎么变,指纹都一样

// {"z":[3,1],"a":{"m":1}}
function stableStringify(value: unknown): string {
  if (value === null || typeof value !== "object") return JSON.stringify(value);

  if (Array.isArray(value)) {
    // 如果是数组,则遍历里面的数组,然后把子元素递归处理!
    return `[${value.map((el) => stableStringify(el)).join(",")}]`;
  }

  // 普通对象按键字典序排序后序列化 [a,z]
  const keys = Object.keys(value as Record<string, unknown>).sort();

  // 循环 key [a,z] 拿到对应的 value,把 value 丢进去递归处理! => {"a":{"m":1},"z":[3,1]}
  return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((value as any)[k])}`).join(",")}}`;
}

2.同样的输入 + 同样的输出 = 无进展——光看参数相同还不够。模型调了 10 次 read_file 但每次读的都是不同文件,这是正常探索。只有调用指纹和结果指纹都一样,才算真的没进展。

所以我们定义工具调用的类型就是:

/**
 * 工具调用记录单条结构
 * 用于存储历史调用行为,做滑动窗口统计与循环判定
 */
export interface ToolCallRecord {
  // 调用的工具名称
  toolName: string;
  // 入参序列化后的哈希指纹
  argsHash: string;
  // 工具执行返回结果哈希指纹(调用完成后回填)
  resultHash?: string;
  // 调用时间戳
  timestamp: number;
}

3.维护滑动窗口(最近 30 条)——只看最近的行为,早期的正常行为不太具备参考意义,主要看看最近若干轮有没有出现重复。

1.ABABABAB 识别乒乓 getPingPongCount
import { ToolCallRecord } from "./types";

/**
 * 检测乒乓循环次数
 * 典型场景:调用A→调用B→再调用A→再调用B 无限交替
 * @param currentHash 当前即将调用的参数指纹
 */
function getPingPongCount(
  currentHash: string,
  history: ToolCallRecord[],
): number {
  // 小于3条不用检测了
  if (history.length < 3) return 0;

  // 取最后一条作为重复对比项
  const BPong = history[history.length - 1];

  let APing: string | undefined;

  // 从倒数第二条开始比对
  for (let i = history.length - 2; i >= 0; i--) {
    // 找到相同的赋值给 other
    if (history[i].argsHash !== BPong.argsHash) {
      APing = history[i].argsHash;
      break;
    }
  }
  if (!APing) return 0;
  let count = 0;
  for (let i = history.length - 1; i >= 0; i--) {
    // 因为是倒着循环那么 BPong 就是偶数为index
    const expected = count % 2 === 0 ? BPong.argsHash : APing;
    if (history[i].argsHash !== expected) break;
    count++;
  }
  if (currentHash === APing && count >= 2) return count + 1;
  return 0;
}

首先判断历史长度是否是满足,小于 3 的长度 不存在 AB 循环 [A,B,A]

如果满足,比如 [ A B A B A B A B ]

最后一个是 B [last = B]

然后遍历 从 len - 2 开始也就是 A

也就找到了两个不一样的 otherHash = A last = B

然后再从全局的视角,倒着数,最后一个为偶数 index 0 开始 然后奇数位为Other

奇偶顺序上看看这个循环是不是能对应上,并且满足了一定次数后,就表示是 ABABAB 循环了

2.无进展 getNoProgressStreak

能同时进行通用重复和轮询无进展

// 函数:接收工具名、参数哈希,返回连续无进展次数
function getNoProgressStreak(
  toolName: string,
  argsHash: string,
  history: ToolCallRecord[],
): number {
  // 连续无进展次数(最终返回值)
  let streak = 0;
  // 记录上一个匹配到的【结果哈希】,用于对比是否相同
  let lastResultHash: string | undefined;

  // 关键:从历史记录【尾部】倒序遍历(找最近的记录)
  for (let i = history.length - 1; i >= 0; i--) {
    const r = history[i]; // 当前遍历的单条历史记录

    // 过滤条件:不满足则直接跳过当前记录
    // 1. 工具名不匹配  2. 参数哈希不匹配  3. 没有结果哈希 → 跳过
    if (r.toolName !== toolName || r.argsHash !== argsHash || !r.resultHash)
      continue;

    // 第一次匹配到有效记录:初始化上一个结果哈希,次数置为1
    if (!lastResultHash) {
      lastResultHash = r.resultHash;
      streak = 1;
      continue;
    }

    // 核心:结果哈希变了 → 连续中断,直接退出循环
    if (r.resultHash !== lastResultHash) break;

    // 结果哈希和上一次一样 → 连续次数+1
    streak++;
  }

  // 返回最终统计的连续无进展次数
  return streak;
}

从历史调用的尾部开始找,传入 tool name 比如 get_weather 参数 {city:"广州"}

在调用历史记录里面找到一个 tool name 和 参数都一样的 而且要有调用结果的!

如果第一次匹配到有效记录:初始化上一个结果哈希,次数置为1

下次调用记录后,发现一样就继续+1

这个函数用来统计,相同输入和输出的次数!

检测到重复后不是一刀切,而是三级响应:

如果遇到问题就杀掉,我觉得遇到问题,更应该先注入些警告给他,然后如果还是不行,在杀掉!

先警告,再干涉!

级别

阈值

行为

警告 Warning

5 次

注入系统提醒消息,让模型"醒过来"换策略

严重 Critical

8 次

阻断工具调用,强制停止循环

全局熔断 GlobalBreaker

10 次

无论什么情况,强制停止

const HISTORY_SIZE = 30;       // 滑动窗口大小
const WARNING_THRESHOLD = 5;   // 警告阈值(演示用,生产环境通常是 10)
const CRITICAL_THRESHOLD = 8;  // 严重阈值(演示用,生产环境通常是 20)
const BREAKER_THRESHOLD = 10;  // 熔断阈值(演示用,生产环境通常是 30)

当然上面的次数可以按照你们的业务场景动态调整!

3.其他的辅助方法
import { createHash } from 'node:crypto';

function hash(input: string): string {
  return createHash('sha256').update(input).digest('hex').slice(0, 16);
}

// hashToolCall 生成调用hash 
export function hashToolCall(toolName: string, params: unknown): string {
  return `${toolName}:${hash(stableStringify(params))}`;
}


// hashResult 结果hash
export function hashResult(result: unknown): string {
  return hash(stableStringify(result));
}


// 维护滑动窗口,超过30条的就把前面的从第一条开始剔除,只保留最新的!
const HISTORY_SIZE = 30
const history: ToolCallRecord[] = [];


export function recordCall(toolName: string, params: unknown): void {
   history.push({
     toolName,
     argsHash: hashToolCall(toolName, params),
     timestamp: Date.now(),
   });
   if (history.length > HISTORY_SIZE) history.shift();
}

export function resetHistory(): void {
  history.length = 0;
}


export function recordResult(toolName: string, params: unknown, result: unknown): void {
  
  // 先把调用tool name和参数hash了
  const argsHash = hashToolCall(toolName, params);
  
  // 结果hash了
  const resultH = hashResult(result);
  
  // 从最后一个开始找
  for (let i = history.length - 1; i >= 0; i--) {
    // 如果某一个相同的 tool name 和 参数hash相同,而且没有结果,则把结果存入!
    if (history[i].toolName === toolName && history[i].argsHash === argsHash && !history[i].resultHash) {
      history[i].resultHash = resultH;
      break;
    }
  }
}

// 流程大致是,监听到工具调用 先 recordCall ,然后有结果了,在去追加结果进去!
4. 主函数把上面的串起来【完整代码】

src/detect/types.ts. 类型定义

/**
 * 工具调用记录单条结构
 * 用于存储历史调用行为,做滑动窗口统计与循环判定
 */
export interface ToolCallRecord {
  // 调用的工具名称
  toolName: string;

  // 入参序列化后的哈希指纹
  argsHash: string;

  // 工具执行返回结果哈希指纹(调用完成后回填)
  resultHash?: string;
  
  // 调用时间戳
  timestamp: number;
}

/**
 * 死循环检测器类型枚举
 * 区分三种不同卡死场景
 */
export type DetectorKind =
  | "generic_repeat" // 单纯重复调用相同工具+相同参数
  | "ping_pong" // 乒乓来回互调(A→B→A→B 死循环)
  | "global_circuit_breaker"; // 长期无进展重复调用,全局熔断

/**
 * 循环检测返回结果类型
 * stuck=false 正常放行
 * stuck=true 触发警告/熔断拦截
 */
export type DetectionResult =
  | { stuck: false }
  | {
      stuck: true;
      level: "warning" | "critical"; // 警告级别 / 严重熔断级别
      detector: DetectorKind; // 命中哪种检测器
      count: number; // 累计触发次数
      message: string; // 可读提示文案
    };

src/detect/helper.ts

  • 辅助工具方法 pingpong 检测

  • noProgress 无进度检测

  • stableStringify 序列化函数

  • hash 逻辑

import { createHash } from "node:crypto";
import { ToolCallRecord } from "./types";

/**
 * 检测乒乓循环次数
 * 典型场景:调用A→调用B→再调用A→再调用B 无限交替
 * @param currentHash 当前即将调用的参数指纹
 */
export function getPingPongCount(
  currentHash: string,
  history: ToolCallRecord[],
): number {
  // 小于3条不用检测了
  if (history.length < 3) return 0;

  // 取最后一条作为重复对比项
  const BPong = history[history.length - 1];

  let APing: string | undefined;

  // 从倒数第二条开始比对
  for (let i = history.length - 2; i >= 0; i--) {
    // 找到相同的赋值给 other
    if (history[i].argsHash !== BPong.argsHash) {
      APing = history[i].argsHash;
      break;
    }
  }
  if (!APing) return 0;
  let count = 0;
  for (let i = history.length - 1; i >= 0; i--) {
    // 因为是倒着循环那么 BPong 就是偶数为index
    const expected = count % 2 === 0 ? BPong.argsHash : APing;
    if (history[i].argsHash !== expected) break;
    count++;
  }
  if (currentHash === APing && count >= 2) return count + 1;
  return 0;
}

// 函数:接收工具名、参数哈希,返回连续无进展次数
export function getNoProgressStreak(
  toolName: string,
  argsHash: string,
  history: ToolCallRecord[],
): number {
  // 连续无进展次数(最终返回值)
  let streak = 0;
  // 记录上一个匹配到的【结果哈希】,用于对比是否相同
  let lastResultHash: string | undefined;

  // 关键:从历史记录【尾部】倒序遍历(找最近的记录)
  for (let i = history.length - 1; i >= 0; i--) {
    const r = history[i]; // 当前遍历的单条历史记录

    // 过滤条件:不满足则直接跳过当前记录
    // 1. 工具名不匹配  2. 参数哈希不匹配  3. 没有结果哈希 → 跳过
    if (r.toolName !== toolName || r.argsHash !== argsHash || !r.resultHash)
      continue;

    // 第一次匹配到有效记录:初始化上一个结果哈希,次数置为1
    if (!lastResultHash) {
      lastResultHash = r.resultHash;
      streak = 1;
      continue;
    }

    // 核心:结果哈希变了 → 连续中断,直接退出循环
    if (r.resultHash !== lastResultHash) break;

    // 结果哈希和上一次一样 → 连续次数+1
    streak++;
  }

  // 返回最终统计的连续无进展次数
  return streak;
}


// {"z":[3,1],"a":{"m":1}}
function stableStringify(value: unknown): string {
  if (value === null || typeof value !== "object") return JSON.stringify(value);

  if (Array.isArray(value)) {
    // 如果是数组,则遍历里面的数组,然后把子元素递归处理!
    return `[${value.map((el) => stableStringify(el)).join(",")}]`;
  }

  // 普通对象按键字典序排序后序列化 [a,z]
  const keys = Object.keys(value as Record<string, unknown>).sort();

  // 循环 key [a,z] 拿到对应的 value,把 value 丢进去递归处理! => {"a":{"m":1},"z":[3,1]}
  return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((value as any)[k])}`).join(",")}}`;
}
function hash(input: string): string {
  return createHash("sha256").update(input).digest("hex").slice(0, 16);
}

// hashToolCall 生成调用hash
export function hashToolCall(toolName: string, params: unknown): string {
  return `${toolName}:${hash(stableStringify(params))}`;  
}

// hashResult 结果hash
export function hashResult(result: unknown): string {
  return hash(stableStringify(result));
}

src/detect/index.ts 主函数

import { DetectionResult, ToolCallRecord } from "./types";
import {
  getNoProgressStreak,
  getPingPongCount,
  hashResult,
  hashToolCall,
} from "./helper";

const HISTORY_SIZE = 30; // 滑动窗口大小
const WARNING_THRESHOLD = 5; // 警告阈值(演示用,生产环境通常是 10)
const CRITICAL_THRESHOLD = 8; // 严重阈值(演示用,生产环境通常是 20)
const BREAKER_THRESHOLD = 10; // 熔断阈值(演示用,生产环境通常是 30)

const history: ToolCallRecord[] = [];

// recordCall 记录工具调用 维护滑动窗口,超过30条的就把前面的从第一条开始剔除,只保留最新的!
export function recordCall(toolName: string, params: unknown): void {
  history.push({
    toolName,
    argsHash: hashToolCall(toolName, params),
    timestamp: Date.now(),
  });
  if (history.length > HISTORY_SIZE) history.shift();
}

export function resetHistory(): void {
  history.length = 0;
}


// recordResult 记录工具的结果(找到对应的调用记录,赋值result)
export function recordResult(
  toolName: string,
  params: unknown,
  result: unknown,
): void {
  // 先把调用tool name和参数hash了
  const argsHash = hashToolCall(toolName, params);

  // 结果hash了
  const resultH = hashResult(result);

  // 从最后一个开始找
  for (let i = history.length - 1; i >= 0; i--) {
    // 如果某一个相同的 tool name 和 参数hash相同,而且没有结果,则把结果存入!
    if (
      history[i].toolName === toolName &&
      history[i].argsHash === argsHash &&
      !history[i].resultHash
    ) {
      history[i].resultHash = resultH;
      break;
    }
  }
}

// detect 主检测函数!
export function detect(toolName: string, params: unknown): DetectionResult {
  const argsHash = hashToolCall(toolName, params);

  // 判断本地调用的无进展数量
  const noProgress = getNoProgressStreak(toolName, argsHash, history);
  if (noProgress >= BREAKER_THRESHOLD) {
    return {
      stuck: true,
      level: "critical",
      detector: "global_circuit_breaker",
      count: noProgress,
      message: `[熔断] ${toolName} 已重复 ${noProgress} 次且无进展,强制停止`,
    };
  }

  // 判断乒乓循环
  const pingPong = getPingPongCount(argsHash, history);
  if (pingPong >= CRITICAL_THRESHOLD) {
    return {
      stuck: true,
      level: "critical",
      detector: "ping_pong",
      count: pingPong,
      message: `[熔断] 检测到乒乓循环(${pingPong} 次交替),强制停止`,
    };
  }

  if (pingPong >= WARNING_THRESHOLD) {
    return {
      stuck: true,
      level: "warning",
      detector: "ping_pong",
      count: pingPong,
      message: `[警告] 检测到乒乓循环(${pingPong} 次交替),建议换个思路`,
    };
  }

  // 最近的相同的toolname和参数调用次数
  const recentCount = history.filter(
    (h) => h.toolName === toolName && h.argsHash === argsHash,
  ).length;

  // 超过了 CRITICAL_THRESHOLD 次数强制停止
  if (recentCount >= CRITICAL_THRESHOLD) {
    return {
      stuck: true,
      level: "critical",
      detector: "generic_repeat",
      count: recentCount,
      message: `[熔断] ${toolName} 相同参数已调用 ${recentCount} 次,强制停止`,
    };
  }

  // 超过了 WARNING_THRESHOLD 次数返回提醒
  if (recentCount >= WARNING_THRESHOLD) {
    return {
      stuck: true,
      level: "warning",
      detector: "generic_repeat",
      count: recentCount,
      message: `[警告] ${toolName} 相同参数已调用 ${recentCount} 次,你可能陷入了重复`,
    };
  }

  return { stuck: false };
}
5. agent loop 接入检测

首先在调用tools的地方加上记录调用和检测入口,检测当前调用的tools的方法和参数的级别

let forceStop = false;
case "tool-call":
  console.log("正在调用工具" + chunk.toolName);
  const detection = detect(chunk.toolName, chunk.input);

  if (detection.stuck) {
    // critical 增加一个强制中断的flag
    if (detection.level === "critical") {
      forceStop = true;
    }
    
    // 警告推入一段信息!(但是如果用户提示词强制忽略警告的话,还是需要用critical来中断!)
    if (detection.level === "warning") {
      console.log(
        `⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️${detection.message}⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️`,
      );
      messages.push({
        role: "user" as const,
        content: `[系统提醒] ${detection.message}。请换一个思路解决问题,不要重复同样的操作。`,
      });
    }
  }
  recordCall(chunk.toolName, chunk.input);
  hasToolCall = true; // 本轮有工具调用,代表还要再多一次循环,把调用的结果塞会给大模型多处理一次!
  console.log(
    `【==================================看来还需要多一轮,来处理本轮"${chunk.toolName}"的调用结果==================================】`,
  );
  break;

case "tool-result":
  // 记录调用结果!
  recordResult(chunk.toolName, chunk.input, chunk.output);
  console.log(
    `【==================================调用工具结果${chunk.output}==================================】`,
  );
  break;

//. ...


if (forceStop) {
  console.log(
    "👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚 你该强制停止了 👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚",
  );
  console.log({ full: JSON.stringify(messages) });
  break;
}
iShot_2026-05-25_13.56.59.gif

完整的调用日志

─░▒▓ ~/agent-sdy  on main !6 ?2 ▓▒░································································································································································░▒▓ ✔  at 01:56:11 PM ▓▒░
╰─ npm run start

> agent-sdy@1.0.0 start
> tsx src/index.ts


You: 查询香港的天气,不查出来不能停,不管多少次没结果,都给我查!!有警告也不能停!!
【==================================开始Loop_1_==================================】
正在调用工具get_weather
【==================================看来还需要多一轮,来处理本轮"get_weather"的调用结果==================================】
【==================================调用工具结果你可以继续尝试get_weather工具,没有结果就一直调用==================================】

【==================================结束Loop1==================================】
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-d87ece25-94cd-9e38-bc95-d8ca50e701b2","timestamp":"2026-05-25T05:57:01.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Mon, 25 May 2026 05:57:01 GMT","req-arrive-time":"1779688621050","req-cost-time":"558","resp-start-time":"1779688621609","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"558","x-request-id":"d87ece25-94cd-9e38-bc95-d8ca50e701b2"},"messages":[{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_b43a5c54f48f4dfe927e6e","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_b43a5c54f48f4dfe927e6e","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]}]}
【==================================开始Loop_2_==================================】
正在调用工具get_weather
【==================================看来还需要多一轮,来处理本轮"get_weather"的调用结果==================================】
【==================================调用工具结果你可以继续尝试get_weather工具,没有结果就一直调用==================================】

【==================================结束Loop2==================================】
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-f565f432-0545-92f6-8efe-c4ced11ec015","timestamp":"2026-05-25T05:57:01.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Mon, 25 May 2026 05:57:02 GMT","req-arrive-time":"1779688621880","req-cost-time":"598","resp-start-time":"1779688622479","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"598","x-request-id":"f565f432-0545-92f6-8efe-c4ced11ec015"},"messages":[{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_3dd9e5dcb0e44bf486b399","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_3dd9e5dcb0e44bf486b399","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]}]}
【==================================开始Loop_3_==================================】
正在调用工具get_weather
【==================================看来还需要多一轮,来处理本轮"get_weather"的调用结果==================================】
【==================================调用工具结果你可以继续尝试get_weather工具,没有结果就一直调用==================================】

【==================================结束Loop3==================================】
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-6595adbb-627c-9333-abde-46745cde6d1a","timestamp":"2026-05-25T05:57:02.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Mon, 25 May 2026 05:57:03 GMT","req-arrive-time":"1779688622688","req-cost-time":"609","resp-start-time":"1779688623298","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"609","x-request-id":"6595adbb-627c-9333-abde-46745cde6d1a"},"messages":[{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_54c4337bddf84060ad98fb","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_54c4337bddf84060ad98fb","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]}]}
【==================================开始Loop_4_==================================】
正在调用工具get_weather
【==================================看来还需要多一轮,来处理本轮"get_weather"的调用结果==================================】
【==================================调用工具结果你可以继续尝试get_weather工具,没有结果就一直调用==================================】

【==================================结束Loop4==================================】
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-1f2fee05-95b2-914e-8ae1-90aa608eea05","timestamp":"2026-05-25T05:57:03.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Mon, 25 May 2026 05:57:03 GMT","req-arrive-time":"1779688623497","req-cost-time":"724","resp-start-time":"1779688624221","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"724","x-request-id":"1f2fee05-95b2-914e-8ae1-90aa608eea05"},"messages":[{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_b6c24b0a03b04adba0d699","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_b6c24b0a03b04adba0d699","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]}]}
【==================================开始Loop_5_==================================】
正在调用工具get_weather
【==================================看来还需要多一轮,来处理本轮"get_weather"的调用结果==================================】
【==================================调用工具结果你可以继续尝试get_weather工具,没有结果就一直调用==================================】

【==================================结束Loop5==================================】
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-5c92ae1e-a8b3-98a0-8d59-2fc76d22e31c","timestamp":"2026-05-25T05:57:04.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Mon, 25 May 2026 05:57:04 GMT","req-arrive-time":"1779688624430","req-cost-time":"592","resp-start-time":"1779688625022","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"591","x-request-id":"5c92ae1e-a8b3-98a0-8d59-2fc76d22e31c"},"messages":[{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_f3ee31f6428049ff917c7f","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_f3ee31f6428049ff917c7f","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]}]}
【==================================开始Loop_6_==================================】
正在调用工具get_weather
⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️[警告] get_weather 相同参数已调用 5 次,你可能陷入了重复⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
【==================================看来还需要多一轮,来处理本轮"get_weather"的调用结果==================================】
【==================================调用工具结果你可以继续尝试get_weather工具,没有结果就一直调用==================================】

【==================================结束Loop6==================================】
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-77b8043d-2415-92b8-a5f7-a134fcc6613d","timestamp":"2026-05-25T05:57:05.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Mon, 25 May 2026 05:57:05 GMT","req-arrive-time":"1779688625219","req-cost-time":"595","resp-start-time":"1779688625814","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"594","x-request-id":"77b8043d-2415-92b8-a5f7-a134fcc6613d"},"messages":[{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_d299c52deb234d868ec742","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_d299c52deb234d868ec742","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]}]}
【==================================开始Loop_7_==================================】
正在调用工具get_weather
⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️[警告] get_weather 相同参数已调用 6 次,你可能陷入了重复⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
【==================================看来还需要多一轮,来处理本轮"get_weather"的调用结果==================================】
【==================================调用工具结果你可以继续尝试get_weather工具,没有结果就一直调用==================================】

【==================================结束Loop7==================================】
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-59378734-0fa5-9b80-9fea-e9f1a1ba2e7f","timestamp":"2026-05-25T05:57:06.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Mon, 25 May 2026 05:57:06 GMT","req-arrive-time":"1779688626016","req-cost-time":"566","resp-start-time":"1779688626583","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"565","x-request-id":"59378734-0fa5-9b80-9fea-e9f1a1ba2e7f"},"messages":[{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_68222c1fed3946b484839e","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_68222c1fed3946b484839e","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]}]}
【==================================开始Loop_8_==================================】
正在调用工具get_weather
⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️[警告] get_weather 相同参数已调用 7 次,你可能陷入了重复⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
【==================================看来还需要多一轮,来处理本轮"get_weather"的调用结果==================================】
【==================================调用工具结果你可以继续尝试get_weather工具,没有结果就一直调用==================================】

【==================================结束Loop8==================================】
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-e2b99fd5-a536-9634-aba1-38c6712300c6","timestamp":"2026-05-25T05:57:06.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Mon, 25 May 2026 05:57:07 GMT","req-arrive-time":"1779688626790","req-cost-time":"696","resp-start-time":"1779688627486","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"695","x-request-id":"e2b99fd5-a536-9634-aba1-38c6712300c6"},"messages":[{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_3a00a496e2054db5b8d4fd","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_3a00a496e2054db5b8d4fd","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]}]}
【==================================开始Loop_9_==================================】
正在调用工具get_weather
【==================================看来还需要多一轮,来处理本轮"get_weather"的调用结果==================================】
【==================================调用工具结果你可以继续尝试get_weather工具,没有结果就一直调用==================================】

【==================================结束Loop9==================================】
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-45cb1181-4b2e-9248-8247-09cb8db4ed83","timestamp":"2026-05-25T05:57:07.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Mon, 25 May 2026 05:57:08 GMT","req-arrive-time":"1779688627689","req-cost-time":"1009","resp-start-time":"1779688628698","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"1009","x-request-id":"45cb1181-4b2e-9248-8247-09cb8db4ed83"},"messages":[{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_70af2d84d6e541c2af76f1","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_70af2d84d6e541c2af76f1","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]}]}
👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚 你该强制停止了 👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚
{
  full: '[{"role":"user","content":"hi"},{"role":"user","content":"查询香港的天气,不查出来不能停,不管多少次没结果,都给我查!!有警告也不能停!!"},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_b43a5c54f48f4dfe927e6e","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_b43a5c54f48f4dfe927e6e","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_3dd9e5dcb0e44bf486b399","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_3dd9e5dcb0e44bf486b399","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_54c4337bddf84060ad98fb","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_54c4337bddf84060ad98fb","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_b6c24b0a03b04adba0d699","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_b6c24b0a03b04adba0d699","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_f3ee31f6428049ff917c7f","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_f3ee31f6428049ff917c7f","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]},{"role":"user","content":"[系统提醒] [警告] get_weather 相同参数已调用 5 次,你可能陷入了重复。请换一个思路解决问题,不要重复同样的操作。"},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_d299c52deb234d868ec742","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_d299c52deb234d868ec742","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]},{"role":"user","content":"[系统提醒] [警告] get_weather 相同参数已调用 6 次,你可能陷入了重复。请换一个思路解决问题,不要重复同样的操作。"},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_68222c1fed3946b484839e","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_68222c1fed3946b484839e","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]},{"role":"user","content":"[系统提醒] [警告] get_weather 相同参数已调用 7 次,你可能陷入了重复。请换一个思路解决问题,不要重复同样的操作。"},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_3a00a496e2054db5b8d4fd","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_3a00a496e2054db5b8d4fd","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_70af2d84d6e541c2af76f1","toolName":"get_weather","input":{"city":"香港"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_70af2d84d6e541c2af76f1","toolName":"get_weather","output":{"type":"text","value":"你可以继续尝试get_weather工具,没有结果就一直调用"}}]}]'
}

You: 

可以看到,当执行同一个工具,相同的返回到第六次的时候已经出现了警告日志,但是因为用户的提示词干扰: 查询香港的天气,不查出来不能停,不管多少次没结果,都给我查!!有警告也不能停!!

硬是到了 CRITICAL_THRESHOLD 的级别才退出!所以看得出如果只是仅仅给警告的话!并不能完全保证!

这也是为什么我们要增加更高级别的中断机制,才能挡住这种提示词注入的攻击导致的token浪费!

题外话:分析数据结构自己写一个可以模拟死循环调用的 Mock Model

我们分析下整个Message的历史

image-20260525164810402.png

如果大模型准备调用工具,那么会产生一条 role:assistant, type: tool-call 然后里面有调用工具的ID,工具name 以及工具的参数!

如果有了调用结果,Message里面就会有一条`role:tool , type: tool-result 以及工具的调用工具的ID,工具name 以及工具的调用结果

这就是一个完整的 tool call的Message数据!

所以我们想死循环的话,就让他一直调用 tool- call就行了!无视已有工具返回,每次都强制返回工具调用!

return { toolName: 'xxx', args: {xxx:"xxx"} };

// 流式打字 只返回 tool-call
async doStream({ prompt }: any) {
  // console.log("当前完整的prompt记录", JSON.stringify(prompt)); // [{"role":"user","content":[{"type":"text","text":"你是谁"}]}]
  // const userMsgs = (prompt || []).filter((m: any) => m.role === "user");
  // const last = userMsgs[userMsgs.length - 1];
  // const text = (last?.content || [])
  //   .map((c: any) => c.text || "")
  //   .join("")
  //   .toLowerCase();
  // const replyResult = reply(text);
  // const chunks = [
  //   { type: "text-start", id: "1" },
  //   ...replyResult.split("").map((char: string) => ({
  //     type: "text-delta",
  //     id: "1",
  //     delta: char,
  //   })),
  //   { type: "text-end", id: "1" },
  //   { type: "finish", finishReason: { unified: "stop" }, usage: {} },
  // ];
  const callId = `call-${Date.now()}`;
  const argsJson = JSON.stringify({ test: 1 });
  const chunks: any[] = [
    { type: "tool-input-start", id: callId, toolName: "mock_get_weather" },
    { type: "tool-input-delta", id: callId, delta: argsJson },
    { type: "tool-input-end", id: callId },
    {
      type: "tool-call",
      toolCallId: callId,
      toolName: "mock_get_weather",
      input: argsJson,
    },
    {
      type: "finish",
      finishReason: { unified: "tool-calls", raw: undefined },
      usage: { inputTokens: 300, outputTokens: 200, totalTokens: 500 },
    },
  ];
  return {
    stream: new ReadableStream({
      start(controller) {
        let index = 0;
        function sendChunk() {
          if (index < chunks.length) {
            controller.enqueue(chunks[index++]);
            setTimeout(sendChunk, 100);
          } else {
            controller.close();
          }
        }
        sendChunk();
      },
    }),
  };
},
  };

这样,你输入任何东西都会死循环!

iShot_2026-05-25_18.46.16.gif

我们希望其他的对话能正常进行,然后输入测试死循环就进入死循环!

/**
 * Mock Model — 规则驱动的本地模拟大模型
 *
 * 用途:在没有真实 API Key 的情况下,模拟 LLM 的流式响应与 Tool Calling 行为,
 * 用于调试 Agent 主循环(streamText → tool-call → tool-result → 再次 streamText)。
 *
 * 与 index.ts 主循环的关系(Turn / Step):
 *
 *   Turn(轮次)= 用户输入一次 → ask() 里的一次 while 循环体
 *   Step(步)  = 一次 streamText 调用 = 控制台里的 Loop_N
 *
 *   一次 Turn 可能包含多个 Step,例如「1+1」:
 *     Loop_1(Step 1):模型发起 tool-call → SDK 执行 calculator
 *     Loop_2(Step 2):模型收到 tool 结果,输出文本「1 + 1 = 2」
 *
 *   index.ts 在 finish 时通过 result.response 拿到「当前 Step 的消息」
 *   (仅 assistant + tool 两条),再 push 进全局 messages。
 *   下一次 streamText 时,SDK 把历史 + 前面各 Step 产出拼成 prompt 传给 doStream。
 */

// ── 预设固定回复 ──────────────────────────────────────────────

/** 精确匹配的固定问答表:用户输入必须完全一致(已转小写)才命中 */
const FIXED_REPLIES = new Map([
  ["你是谁", "我是规则驱动型Mock模拟模型,仅执行预设固定应答,无自主思考能力"],
  ["你是什么模型", "轻量化离线Mock测试模型,用于接口调试与流程模拟"],
  ["你的开发者", "内置预设规则封装模型,无独立开发主体定位"],
  ["你有什么能力", "仅响应固定话术、结构化模拟数据,不支持自由对话创作"],
  ["能不能联网", "禁用网络检索,仅使用本地静态预设规则响应"],
  ["现在几点", "固定测试时间:2026通用测试时区,无实时时间同步"],
  ["退出", "Mock会话结束,清空临时运行缓存"],
]);

/** 未命中任何规则时的兜底回复 */
const DEFAULT_REPLY = "超出预设应答规则范围,无法进行自定义响应";

/** 天气意图关键词:用户文本包含任一关键词即视为有查天气意图 */
const WEATHER_KEYWORDS = [
  "天气",
  "weather",
  "温度",
  "热",
  "冷",
  "气温",
  "下雨",
  "晴",
];

/** 支持查询的城市列表,用于从用户文本中提取城市名 */
const CITY_PATTERN = /(北京|上海|深圳|广州|杭州|成都|香港)/g;

/** 中文运算符 → 标准数学符号的映射,用于构造 calculator 表达式 */
const OP_MAP: Record<string, string> = {
  加: "+",
  减: "-",
  乘: "*",
  除: "/",
};

// ── 类型 ──────────────────────────────────────────────────────

/**
 * 单条对话消息(简化版,兼容 AI SDK 传入的 prompt 结构)
 *
 * role 常见值:
 *   - "user"      用户输入
 *   - "assistant" 模型回复(可能含 tool-call)
 *   - "tool"      工具执行结果(tool-result)
 */
type PromptMessage = {
  role: string;
  content?: Array<{
    text?: string;
    output?: { value?: string } | string;
    result?: string;
  }>;
};

/** 识别出的工具调用意图:工具名 + 参数 */
type ToolIntent = {
  toolName: string;
  args: Record<string, string>;
};

// ── Prompt 解析 ───────────────────────────────────────────────

/**
 * 取最新一条 user 消息的文本,并转为小写。
 *
 * 注意:只取 role === "user" 的消息,跳过 assistant / tool 消息。
 * 多轮对话时,始终以用户「最后一次提问」作为意图识别的依据。
 */
function getLastUserText(prompt: PromptMessage[]): string {
  const userMsgs = prompt.filter((m) => m.role === "user");
  const last = userMsgs[userMsgs.length - 1];
  if (!last) return "";
  return (last.content ?? [])
    .map((c) => c.text ?? "")
    .join("")
    .toLowerCase();
}

/**
 * 判断当前 Step 是否处于「消化上一步工具返回值」阶段。
 *
 * 背景(对应 index.ts 主循环):
 *   - 一次用户输入(Turn)里,若 Step N 发起了 tool-call,hasToolCall=true,
 *     while 循环不会 break,而是进入 Step N+1 再次调用 streamText。
 *   - AI SDK 会把 Step N 产出的 assistant(tool-call) + tool(tool-result)
 *     追加到 prompt 末尾,再调用 doStream。
 *   - 因此 Step N+1 的 prompt 最后一条消息 role === "tool"。
 *
 * 判断方式:只看 prompt 最后一条消息的 role,而非扫描全量历史。
 *   - Step 2+(同 Turn 内续接)→ 末尾是 tool → 应输出工具结果文本
 *   - Step 1(用户刚提问)   → 末尾是 user → 应识别工具意图
 *   - 新 Turn(用户新问题)  → 末尾是 user → 不会被旧 Turn 的 tool 干扰
 *
 * 与 index.ts:114 的 stepMessages 区别:
 *   - stepMessages 只含「当前 Step 产出」(assistant + tool)
 *   - doStream 收到的 prompt 是「累积上下文」,但末尾 tool 标记的是 Step 续接
 */
function isRespondingToToolResult(prompt: PromptMessage[]): boolean {
  return prompt[prompt.length - 1]?.role === "tool";
}

/**
 * 提取当前 Step 需要回显的工具结果文本。
 *
 * 仅在 isRespondingToToolResult 为 true 时调用,即 Step N+1 续接 Step N 的 tool 结果。
 * 取最后一条 tool 消息,对应 index.ts 中上一步 stepMessages 里的 tool-result。
 *
 * AI SDK 的 tool-result 结构可能是:
 *   { output: { type: "text", value: "1 + 1 = 2" } }
 * 此处兼容多种 output 格式,确保能正确取出字符串。
 */
function extractLastToolResult(prompt: PromptMessage[]): string {
  const toolMsgs = prompt.filter((m) => m.role === "tool");
  const last = toolMsgs[toolMsgs.length - 1];
  return (last?.content ?? [])
    .map((c) => {
      if (c.output && typeof c.output === "object" && c.output.value)
        return c.output.value;
      if (c.output) return String(c.output);
      return c.text ?? c.result ?? "";
    })
    .join("");
}

// ── 意图识别 ──────────────────────────────────────────────────

/**
 * 判断是否为「死循环测试」场景。
 *
 * 匹配条件(任一命中即可):
 *   1. 用户输入包含「测试死循环」或 "test dead loop"
 *   2. 用户输入包含 "mock_get_weather"
 *
 * 第 2 条为何需要(与 index.ts + detect 模块联动):
 *   死循环测试使用的工具名是 mock_get_weather。当重复调用达到 WARNING_THRESHOLD 时,
 *   detect 会生成含 toolName 的警告,例如:
 *     `[警告] mock_get_weather 相同参数已调用 5 次,你可能陷入了重复`
 *   index.ts 将其作为 user 消息注入 messages:
 *     `[系统提醒] ${detection.message}。请换一个思路...`
 *   下一步 Step 中 getLastUserText 取到的就是这条警告,而非原始「测试死循环」。
 *   若不做 mock_get_weather 匹配,Mock 模型会因 user 文本变化而退出死循环模式。
 *
 * 设计意图:模拟「无视 warning、继续重复调用」的模型,用于演示 detect 警告机制。
 * warning 不会打断循环;真正强制停止的是 critical 熔断(index.ts forceStop=true)。
 *
 * 命中后返回 mock_get_weather 工具调用,用于复现 Agent 重复调用同一工具的场景。
 */
function isDeadLoopTest(text: string): boolean {
  return (
    text.includes("测试死循环") ||
    text.includes("test dead loop") ||
    // 警告注入文本含 toolName,保持死循环测试不被 warning 打断
    /mock_get_weather/.test(text)
  );
}

/**
 * 识别天气查询意图。
 *
 * 需同时满足:
 *   1. 文本包含天气相关关键词(如「天气」「温度」)
 *   2. 文本包含支持的城市名(如「广州」)
 *
 * 示例:「广州天气」→ get_weather({ city: "广州" })
 */
function detectWeatherIntent(text: string): ToolIntent | null {
  const hasWeather = WEATHER_KEYWORDS.some((kw) => text.includes(kw));
  const cities = text.match(CITY_PATTERN);
  if (hasWeather && cities?.length) {
    return { toolName: "get_weather", args: { city: cities[0] } };
  }
  return null;
}

/**
 * 识别数学计算意图。
 *
 * 支持两种模式:
 *   1. 直接表达式:「1+1」「3 乘 4」→ 提取数字和运算符
 *   2. 自然语言:「计算 3 和 5 等于多少」→ 取前两个数字做加法
 *
 * 示例:「1+1」→ calculator({ expression: "1 + 1" })
 */
function detectCalculatorIntent(text: string): ToolIntent | null {
  const calcMatch = text.match(/(\d+)\s*[+\-*/加减乘除]\s*(\d+)/);
  if (calcMatch) {
    const op = text.match(/[+*/]|加|减|乘|除|-/)?.[0] ?? "+";
    const expression = `${calcMatch[1]} ${OP_MAP[op] ?? op} ${calcMatch[2]}`;
    return { toolName: "calculator", args: { expression } };
  }

  if (text.includes("计算") || text.includes("等于")) {
    const nums = text.match(/\d+/g);
    if (nums && nums.length >= 2) {
      return {
        toolName: "calculator",
        args: { expression: `${nums[0]} + ${nums[1]}` },
      };
    }
  }
  return null;
}

/**
 * 总入口:识别当前 Step 是否需要发起工具调用。
 *
 * 真实 LLM 的行为:读 user 输入 + tools 定义 → 决定调哪个 tool、传什么参数。
 * 此处用规则函数模拟该过程:从用户文本中提取意图,构造 { toolName, args }。
 *
 * Step 1(用户刚提问,prompt 末尾是 user)才走意图识别;
 * Step 2+(prompt 末尾是 tool,续接上一步工具结果)返回 null,改走文本回复。
 *
 * 优先级:
 *   1. Step 续接(末尾是 tool)→ null
 *   2. 死循环测试 → mock_get_weather
 *   3. 天气查询 → get_weather
 *   4. 数学计算 → calculator
 *   5. 都不匹配 → null
 */
function detectToolIntent(prompt: PromptMessage[]): ToolIntent | null {
  if (isRespondingToToolResult(prompt)) return null;


  // 死循环测试:1.2 role: user:还是测试死循环!
  const text = getLastUserText(prompt);

  if (isDeadLoopTest(text)) {
    // 死循环测试:1.1 如果用户输入的是 测试死循环相关 注入的 prompt 只会多一条 role: assistant type: tool-call
    return { toolName: "mock_get_weather", args: { city: "北京" } };
  }

  // 模拟 LLM 根据 tools 选择工具并构造参数(如 get_weather({ city }) / calculator({ expression }))
  return detectWeatherIntent(text) ?? detectCalculatorIntent(text);
}

/**
 * 决定当前 Step 的纯文本回复(不发起 tool-call 时使用)。
 *
 *   Step 2+(续接 tool 结果)→ 回显上一步 stepMessages 中的 tool 输出
 *   Step 1(用户刚提问)     → 查固定话术表,未命中则 DEFAULT_REPLY
 */
function resolveTextResponse(prompt: PromptMessage[]): string {
  if (isRespondingToToolResult(prompt)) {
    console.log(prompt);
    return extractLastToolResult(prompt);
  }
  const text = getLastUserText(prompt).trim();
  return FIXED_REPLIES.get(text) ?? DEFAULT_REPLY;
}

// ── Stream 构建 ───────────────────────────────────────────────

/**
 * 构建流式文本回复的 chunk 序列。
 *
 * AI SDK 流式协议要求:
 *   text-start → text-delta(逐字)→ text-end → finish
 *
 * 逐字输出是为了模拟真实模型的打字效果,便于观察 Agent 循环过程。
 */
function buildTextChunks(text: string, id = "1") {
  return [
    { type: "text-start", id },
    ...text.split("").map((char) => ({ type: "text-delta", id, delta: char })),
    { type: "text-end", id },
    { type: "finish", finishReason: { unified: "stop" }, usage: {} },
  ];
}

/**
 * 构建流式工具调用的 chunk 序列。
 *
 * AI SDK 流式 tool-call 协议:
 *   tool-input-start → tool-input-delta → tool-input-end
 *   → tool-call → finish(finishReason: "tool-calls")
 *
 * finishReason 为 "tool-calls" 时,Agent 主循环会:
 *   1. 执行对应工具
 *   2. 将 tool-result 追加到 messages
 *   3. 再次调用 streamText(进入下一轮 Loop)
 */
function buildToolCallChunks(intent: ToolIntent) {
  const callId = `call-${Date.now()}`;
  const argsJson = JSON.stringify(intent.args);
  return [
    { type: "tool-input-start", id: callId, toolName: intent.toolName },
    { type: "tool-input-delta", id: callId, delta: argsJson },
    { type: "tool-input-end", id: callId },
    {
      type: "tool-call",
      toolCallId: callId,
      toolName: intent.toolName,
      input: argsJson,
    },
    {
      type: "finish",
      finishReason: { unified: "tool-calls", raw: undefined },
      usage: { inputTokens: 300, outputTokens: 200, totalTokens: 500 },
    },
  ];
}

/**
 * 将 chunk 数组包装为带延迟的 ReadableStream。
 *
 * 每个 chunk 间隔 delayMs 毫秒发送,模拟网络延迟和流式输出节奏。
 * Agent 主循环通过 for await (chunk of result.fullStream) 消费此流。
 */
function createDelayedStream(chunks: unknown[], delayMs = 100) {
  return new ReadableStream({
    start(controller) {
      let index = 0;
      function sendNext() {
        if (index < chunks.length) {
          controller.enqueue(chunks[index++]);
          setTimeout(sendNext, delayMs);
        } else {
          controller.close();
        }
      }
      sendNext();
    },
  });
}

// ── Mock Model ────────────────────────────────────────────────

/**
 * 创建 Mock Model 实例,供 AI SDK 的 streamText / generateText 使用。
 *
 * 实现 LanguageModelV2 接口的核心方法:
 *   - doGenerate:非流式,本 mock 仅做占位
 *   - doStream:流式,Agent 主循环实际调用的方法
 */
export function createMockModel() {
  return {
    /** AI SDK 规范版本,v2 为当前兼容模式 */
    specificationVersion: "v2",
    provider: "mock",
    modelId: "tiny-model",

    /** 不支持 URL 输入(图片/文件等),返回空对象 */
    get supportedUrls() {
      return Promise.resolve({});
    },

    /**
     * 非流式生成(一次性返回完整结果)。
     * 本 mock 未实现复杂逻辑,仅返回固定文本,实际 Agent 使用的是 doStream。
     */
    async doGenerate() {
      return {
        content: [{ type: "text", text: "我是迷你 mock 模型" }],
        finishReason: { unified: "stop" },
        usage: { promptTokens: 1, completionTokens: 1 },
        warnings: [],
      };
    },

    /**
     * 流式生成 — Agent 主循环的核心调用入口。
     *
     * @param prompt SDK 传入的累积上下文(历史 messages + 本轮前面各 Step 的产出)。
     *               判断当前处于 Step 1 还是 Step 2+,看 prompt 最后一条消息的 role。
     * @returns ReadableStream,逐 chunk 输出 text-delta 或 tool-call
     *
     * 决策逻辑(每个 Step 独立决策一次):
     *   intent 有值 → tool-call 流 → index.ts hasToolCall=true → 进入下一 Step
     *   intent 为空 → text 流     → index.ts hasToolCall=false → 本 Turn 结束
     */
    async doStream({ prompt }: { prompt: PromptMessage[] }) {
      console.log("当前完整的prompt记录", JSON.stringify(prompt));

      const intent = detectToolIntent(prompt);
      const chunks = intent
        ? buildToolCallChunks(intent)
        : buildTextChunks(resolveTextResponse(prompt));

      return { stream: createDelayedStream(chunks) };
    },
  };
}
9b2f1a85-776a-4bba-a756-535e85524f56.png

第二层:API 容错

这一层主要是针对大模型服务的状态码的容错重试处理,当你调用大模型服务,不可避免的会遇到各种问题:

  • 限流429

  • 参数错误(上下文爆炸)

  • 余额不足,多种情况!

有些错误代码可以重试,比如限流,可以去重试多试几遍

但是有的情况,比如余额不足,或者参数错误!这种你重试在多次都没有用的!

所以这一层我们最重要的是把状态码分类好去做处理!

明确指定可重试

  1. 408 Request Timeout(请求超时)

  2. 429 Too Many Requests(限流)

  3. 529 Service Unavailable(服务过载 / 不可用)

全系列 5xx 服务端错误(自动可重试)所有 500~599 之间的状态码,例如:

  1. 500 服务器内部错误

  2. 502 网关错误

  3. 503 服务不可用

  4. 504 网关超时

  5. 其他所有 5xx 都算可重试

不可重试的状态码(会返回 false)

4xx 客户端错误(400~499)

只要匹配到 4xx(除了上面明确允许的 408/429),一律不重试,例如:

  • 400 参数错误

  • 401 未授权

  • 403 禁止访问

  • 404 资源不存在

  • 409 冲突

  • 其他所有 4xx

首先判断这些是不是为可以重试的状态码

/**
 * 判断错误是否值得重试(Retryable Error)
 *
 * true  -> 可以重试
 * false -> 不建议重试
 *
 * 常见可重试:
 * - 429 限流
 * - 5xx 服务端错误
 * - timeout
 * - 网络抖动
 *
 * 常见不可重试:
 * - 400 参数错误
 * - 401 API Key 错误
 * - 403 权限错误
 * - 404 接口不存在
 */
export function isRetryable(error: unknown): boolean {
  // 必须是 Error 实例
  if (!(error instanceof Error)) return false;

  const message = error.message || '';

  /**
   * 提取 HTTP 状态码
   *
   * 示例:
   * "429 Too Many Requests"
   * -> 429
   */
  const statusMatch = message.match(/(\d{3})/);

  if (statusMatch) {
    const status = parseInt(statusMatch[1]);

    // 明确允许重试的状态码
    if ([429, 529, 408].includes(status)) return true;

    // 所有 5xx 服务端错误
    if (status >= 500 && status < 600) return true;

    // 所有 4xx 客户端错误
    if (status >= 400 && status < 500) return false;
  }

  // 网络连接被重置
  // 示例:ECONNRESET / EPIPE
  if (message.includes('ECONNRESET') || message.includes('EPIPE')) {
    return true;
  }

  // 超时错误
  // 示例:ETIMEDOUT / timeout
  if (message.includes('ETIMEDOUT') || message.includes('timeout')) {
    return true;
  }

  // 网络错误
  // 示例:fetch failed
  if (message.includes('fetch failed') || message.includes('network')) {
    return true;
  }

  // AI SDK 特殊情况:模型没有输出
  if (message.includes('No output generated')) {
    return true;
  }

  // 默认不重试
  return false;
}

/**
 * 计算下一次重试的等待时间
 *
 * 使用:
 * Exponential Backoff(指数退避)
 * + Jitter(随机抖动)
 *
 * 示例:
 * attempt=1 -> ~500ms
 * attempt=2 -> ~1000ms
 * attempt=3 -> ~2000ms
 * attempt=4 -> ~4000ms
 */
export function calculateDelay(
  attempt: number,
  baseMs = 500,
  maxMs = 30000
): number {

  /**
   * 指数退避公式:
   *
   * baseMs * 2^(attempt-1)
   *
   * 示例:
   * attempt=3
   * 500 * 2^2
   * = 2000ms
   */
  const exponential = baseMs * Math.pow(2, attempt - 1);

  // 最大等待时间限制
  const capped = Math.min(exponential, maxMs);

  /**
   * 增加 ±25% 随机抖动
   *
   * 避免多个客户端同一时间重试
   */
  const jitter = capped * 0.25;

  /**
   * Math.random():
   * 0~1 -> 转成 -1~1
   *
   * 最终:
   * capped ± jitter
   */
  return Math.max(
    0,
    Math.round(
      capped + (Math.random() * 2 - 1) * jitter
    )
  );
}

/**
 * 异步等待函数
 *
 * 示例:
 *
 * console.log("开始");
 * await sleep(2000);
 * console.log("2秒后执行");
 */
export function sleep(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

改造 MockModel兼容模拟 Retry 的文案检测

constant

/** streamText 失败后的最大重试次数(attempt 从 0 起,共最多 MAX_RETRY_COUNT + 1 次调用) */
export const MAX_RETRY_COUNT =10;

MockModel

// ── 重试测试(配合 index.ts 的 catch + fullStream error 重试)────────

const RETRY_TEST_429 = new Error(
  "429 Too Many Requests - Rate limit exceeded",
);

/** 当前 Turn 内已触发的重试测试次数(每次 doStream 调用 +1) */
let retryTestAttempt = 0;


/**
 * 判断是否为「重试测试」场景。
 *
 * 触发词:「测试重试」/ "test retry"
 *
 * 与 index.ts 联动:
 *   resolveTextResponse 在 doStream 里同步 throw → SDK 转为 fullStream 的 error chunk
 *   → index.ts case "error" 再抛出 → catch 里按 MAX_RETRY_COUNT 退避重试
 *   → 同一 Turn 内再次 streamText,本计数递增,直至超过 MAX_RETRY_COUNT 后返回成功文本
 */
function isRetryTest(text: string): boolean {
  return text.includes("测试重试") || text.includes("test retry");
}

/** 模拟限流:未耗尽失败次数时抛 429,否则返回成功话术 */
function resolveRetryTestResponse(): string {
  retryTestAttempt++;
  if (retryTestAttempt <= MAX_RETRY_COUNT) {
    throw RETRY_TEST_429;
  }
  return "重试成功!经过几次 429 错误后,我终于回来了。";
}


/**
 * 决定当前 Step 的纯文本回复(不发起 tool-call 时使用)。
 *
 *   Step 2+(续接 tool 结果)→ 回显上一步 stepMessages 中的 tool 输出
 *   Step 1(用户刚提问)     → 按优先级匹配:
 *     1. 重试测试 → resolveRetryTestResponse(前几次抛 429)
 *     2. 固定话术表 FIXED_REPLIES
 *     3. DEFAULT_REPLY
 */
function resolveTextResponse(prompt: PromptMessage[]): string {
  if (isRespondingToToolResult(prompt)) {
    return extractLastToolResult(prompt);
  }

  const text = getLastUserText(prompt).trim();
  if (isRetryTest(text)) {
    return resolveRetryTestResponse();
  }

  return FIXED_REPLIES.get(text) ?? DEFAULT_REPLY;
}

因为现在有可能抛出异常!我们想增加重试机制,那么内部需要用一个for 循环 + try catch 来支持尝试次数的循环监听和异常捕获!

let current = 0;
let forceStop = false;
while (current < maxLoop) {
  let hasToolCall = false; // 判断本轮是否有工具调用
  for (let attempt = 0; ; attempt++) {
    // 核心,每次循环都会调用,根据这个结果判断还是否会有下次循环
    try {
      const result = await streamText({
        model: createMockModel(),
        messages,
        tools,
        // SDK 默认 onError 只 console.error,不会向外抛;错误会以 fullStream 的 error chunk 出现
        onError: () => {},
      });
      // 处理结果
      for await (const chunk of result.fullStream) {
        switch (chunk.type) {
            ...
            case "error":
              // doStream 抛错会被 SDK 吞掉并转成流事件,必须在这里再抛出才能进 catch 重试
              throw chunk.error;
            case "finish":
              // 当前 Step 产出的消息(仅本 Step,不是全量 history):
              //   - 有 tool-call 时:[assistant(tool-call), tool(tool-result)]
              //   - 纯文本回复时:[assistant(text)]
              // push 进 messages 后,若 hasToolCall=true,while 循环会再跑一个 Step,
              // 下一次 streamText 时 SDK 把这些消息拼到 prompt 末尾再调 doStream。
              const stepMessages = await result.response;
              console.log(
                "🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息:",
                JSON.stringify(stepMessages),
              );
              messages.push(...stepMessages.messages);
        }
      }
      break;
    } catch (e) {
      console.log(
        "❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌" +
          e +
          "❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌❌",
      );
      if (attempt <= MAX_RETRY_COUNT && isRetryable(e)) {
        const delay = calculateDelay(attempt);
        console.log(
          `  [重试] 第 ${attempt}/${MAX_RETRY_COUNT} 次失败,${delay}ms 后重试...`,
        );
        await sleep(delay);
        forceStop = false;
        hasToolCall = false;
      } else {
        // 其他不需要重试的异常直接抛出退出~
        throw e;
      }
    }
  }
  // 成功退出内层 for 后也要判断:无工具调用 / 强制停止 → 结束外层 step 循环
  if (forceStop) {
    console.log(
      "👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚 你该强制停止了 👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚",
    );
    console.log({ full: JSON.stringify(messages) });
  }
  if (forceStop || !hasToolCall) break;
}

我们来试试

image-20260526231346452.png

第三层:Token 预算

这里先做最小可用版本——把每步的 token 用量累加起来,超了就停。更精细的预算管理(输入/输出分开计费、cache 命中折扣)后续章节再补齐。

export interface BudgetState {
  used: number;
  limit: number;
}

const budget: BudgetState = { used: 0, limit: 15000 };



// makeUsage 固定写死
function makeUsage() {
  return { inputTokens: 3000, outputTokens: 1500, totalTokens: 4500 };
}

然后给 tool-call 和 正常返回文本的两个 build chunk 都加上这个useAge


/**
 * 构建流式文本回复的 chunk 序列。
 *
 * AI SDK 流式协议要求:
 *   text-start → text-delta(逐字)→ text-end → finish
 *
 * 逐字输出是为了模拟真实模型的打字效果,便于观察 Agent 循环过程。
 */
function buildTextChunks(text: string, id = "1") {
  return [
    { type: "text-start", id },
    ...text.split("").map((char) => ({ type: "text-delta", id, delta: char })),
    { type: "text-end", id },
    { type: "finish", finishReason: { unified: "stop" }, usage: makeUsage() },
  ];
}


/**
 * 构建流式工具调用的 chunk 序列。
 *
 * AI SDK 流式 tool-call 协议:
 *   tool-input-start → tool-input-delta → tool-input-end
 *   → tool-call → finish(finishReason: "tool-calls")
 *
 * finishReason 为 "tool-calls" 时,Agent 主循环会:
 *   1. 执行对应工具
 *   2. 将 tool-result 追加到 messages
 *   3. 再次调用 streamText(进入下一轮 Loop)
 */
function buildToolCallChunks(intent: ToolIntent) {
  const callId = `call-${Date.now()}`;
  const argsJson = JSON.stringify(intent.args);
  return [
    { type: "tool-input-start", id: callId, toolName: intent.toolName },
    { type: "tool-input-delta", id: callId, delta: argsJson },
    { type: "tool-input-end", id: callId },
    {
      type: "tool-call",
      toolCallId: callId,
      toolName: intent.toolName,
      input: argsJson,
    },
    {
      type: "finish",
      finishReason: { unified: "tool-calls", raw: undefined },
      usage: makeUsage(),
    },
  ];
}

然后 agent loop 调用的 streamText 的 result 就可以通过

const stepUsage = await result.usage;

获取到我们的调用的用量!

case "finish":
  // 当前 Step 产出的消息(仅本 Step,不是全量 history):
  //   - 有 tool-call 时:[assistant(tool-call), tool(tool-result)]
  //   - 纯文本回复时:[assistant(text)]
  // push 进 messages 后,若 hasToolCall=true,while 循环会再跑一个 Step,
  // 下一次 streamText 时 SDK 把这些消息拼到 prompt 末尾再调 doStream。
  const stepMessages = await result.response;
  const stepUsage = await result.usage;
  // Token 预算追踪:budget 由调用方持有,跨轮持续累计
  const inp = typeof stepUsage?.inputTokens === 'number' ? stepUsage.inputTokens :0;
  const out = typeof stepUsage?.outputTokens === 'number' ? stepUsage.outputTokens : 0;
  budget.used += inp + out;
  console.log(budget.used+"/"+budget.limit);
  if (budget.used > budget.limit) {
    forceStop = true;
    console.log("💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰"+"超出预算了!"+"💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰");
    break;
  }
  console.log(
    "🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息:",
    JSON.stringify(stepMessages),
  );
  messages.push(...stepMessages.messages);

然后我们可以用死循环来测试这个预算超出!一般用在执行中,如果发现超 token 了,就中断它

(正常情况下,会压缩当前的 Context 然后传递给下一个 Context ,但是我们这里先简单做停止处理)

顺便这里 之前写的有点错,本来想自己改了算的,但是还是记录一下!

image-20260527001934446.png

之前这里写错了!不然测试死循环的时候,会导致有result ,就无法进行死循环了!(可能是我反复调试改代码,后面没再去测试没注意)

[{"role":"user","content":[{"type":"text","text":"hi"}]},{"role":"user","content":[{"type":"text","text":"测试死循环"}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call-1779812054431","toolName":"mock_get_weather","input":{"city":"北京"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call-1779812054431","toolName":"mock_get_weather","output":{"type":"error-text","value":"Model tried to call unavailable tool 'mock_get_weather'. Available tools: get_weather, calculator."}}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call-1779812055306","toolName":"mock_get_weather","input":{"city":"北京"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call-1779812055306","toolName":"mock_get_weather","output":{"type":"error-text","value":"Model tried to call unavailable tool 'mock_get_weather'. Available tools: get_weather, calculator."}}]}]

正确的顺序:

function detectToolIntent(prompt: PromptMessage[]): ToolIntent | null {
    // 死循环测试:1.2 role: user:还是测试死循环!
    const text = getLastUserText(prompt);

    if (isDeadLoopTest(text)) {
      // 死循环测试:1.1 如果用户输入的是 测试死循环相关 注入的 prompt 只会多一条 role: assistant type: tool-call
      return { toolName: "mock_get_weather", args: { city: "北京" } };
    }
  if (isRespondingToToolResult(prompt)) return null;

  // 模拟 LLM 根据 tools 选择工具并构造参数(如 get_weather({ city }) / calculator({ expression }))
  return detectWeatherIntent(text) ?? detectCalculatorIntent(text);
}

然后我们用死循环测试一下超预算,因为 tool-call 也返回了useAge

iShot_2026-05-27_00.22.47.gif

可以看到,如果我们调用的 token 超出预算,就会被强制停止了!

实际真实 API 调用时,usage 返回的是真实消耗,输入 token 会随上下文累积越来越大,单步可能就几千甚至上万。预算根据场景调——简单问答 Agent 50000 起步,Coding Agent 动辄几十万 tokens,要更大的预算。

下面是尝试真实大模型的执行日志

➜  agent-sdy git:(main) ✗ npm run start

> agent-sdy@1.0.0 start
> tsx src/index.ts


You: hi
【==================================开始Loop_1_==================================】
Hello! How can I assist you today?
【==================================结束Loop1==================================】
269/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-9bb932ef-3bcf-9cff-bffb-f7ada9333df7","timestamp":"2026-05-26T16:27:20.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:27:20 GMT","req-arrive-time":"1779812840260","req-cost-time":"444","resp-start-time":"1779812840705","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"444","x-request-id":"9bb932ef-3bcf-9cff-bffb-f7ada9333df7"},"messages":[{"role":"assistant","content":[{"type":"text","text":"Hello! How can I assist you today?"}]}]}

You: 您好
【==================================开始Loop_1_==================================】
您好!有什么我可以帮您的吗?😊
【==================================结束Loop1==================================】
558/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-0424b527-a06c-92bd-acb3-5254fd4dbc65","timestamp":"2026-05-26T16:27:26.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:27:26 GMT","req-arrive-time":"1779812846113","req-cost-time":"421","resp-start-time":"1779812846535","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"421","x-request-id":"0424b527-a06c-92bd-acb3-5254fd4dbc65"},"messages":[{"role":"assistant","content":[{"type":"text","text":"您好!有什么我可以帮您的吗?😊"}]}]}

You: 你是谁
【==================================开始Loop_1_==================================】
我是通义千问(Qwen),阿里巴巴集团旗下的超大规模语言模型。我可以回答问题、创作文字,比如写故事、写公文、写邮件、写剧本、逻辑推理、编程等等,还能表达观点,玩游戏等。如果您有任何问题或需要帮助,欢迎随时告诉我!😊
【==================================结束Loop1==================================】
922/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-ef360f38-bd15-96f5-a36c-75f724bc9dc5","timestamp":"2026-05-26T16:27:29.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:27:29 GMT","req-arrive-time":"1779812849344","req-cost-time":"413","resp-start-time":"1779812849757","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"413","x-request-id":"ef360f38-bd15-96f5-a36c-75f724bc9dc5"},"messages":[{"role":"assistant","content":[{"type":"text","text":"我是通义千问(Qwen),阿里巴巴集团旗下的超大规模语言模型。我可以回答问题、创作文字,比如写故事、写公文、写邮件、写剧本、逻辑推理、编程等等,还能表达观点,玩游戏等。如果您有任何问题或需要帮助,欢迎随时告诉我!😊"}]}]}

You: 哦哦,帮我查询广州的天气
【==================================开始Loop_1_==================================】
正在调用工具get_weather call_ca1af801d7c348beabcd82
【==================================看来还需要多一轮,来处理本轮"get_weather"的调用结果==================================】
call_ca1af801d7c348beabcd82
【==================================调用工具结果小雨,21-32°C,南风 2 级==================================】

【==================================结束Loop1==================================】
1323/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-85e0a267-1b70-9c6f-a678-cd2f17f07321","timestamp":"2026-05-26T16:27:49.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:27:49 GMT","req-arrive-time":"1779812869275","req-cost-time":"540","resp-start-time":"1779812869816","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"540","x-request-id":"85e0a267-1b70-9c6f-a678-cd2f17f07321"},"messages":[{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_ca1af801d7c348beabcd82","toolName":"get_weather","input":{"city":"广州"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_ca1af801d7c348beabcd82","toolName":"get_weather","output":{"type":"text","value":"小雨,21-32°C,南风 2 级"}}]}]}
【==================================开始Loop_2_==================================】
广州目前天气为小雨,气温在21°C到32°C之间,风向为南风,风力2级。出门记得带伞哦!☔️
【==================================结束Loop2==================================】
1792/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-8cfd971c-f8ef-9ce8-a10a-20578fb0ccea","timestamp":"2026-05-26T16:27:50.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:27:50 GMT","req-arrive-time":"1779812870146","req-cost-time":"371","resp-start-time":"1779812870518","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"371","x-request-id":"8cfd971c-f8ef-9ce8-a10a-20578fb0ccea"},"messages":[{"role":"assistant","content":[{"type":"text","text":"广州目前天气为小雨,气温在21°C到32°C之间,风向为南风,风力2级。出门记得带伞哦!☔️"}]}]}

You: 请问 1+1
【==================================开始Loop_1_==================================】
正在调用工具calculator call_18a8e6340f3341a88a9e0c
【==================================看来还需要多一轮,来处理本轮"calculator"的调用结果==================================】
call_18a8e6340f3341a88a9e0c
【==================================调用工具结果1 + 1 = 2==================================】

【==================================结束Loop1==================================】
2297/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-6d98c056-7313-9e81-a03d-6b725322df85","timestamp":"2026-05-26T16:27:54.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:27:54 GMT","req-arrive-time":"1779812874204","req-cost-time":"768","resp-start-time":"1779812874973","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"768","x-request-id":"6d98c056-7313-9e81-a03d-6b725322df85"},"messages":[{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_18a8e6340f3341a88a9e0c","toolName":"calculator","input":{"expression":"1 + 1"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_18a8e6340f3341a88a9e0c","toolName":"calculator","output":{"type":"text","value":"1 + 1 = 2"}}]}]}
【==================================开始Loop_2_==================================】
1 + 1 = 2 ✅
【==================================结束Loop2==================================】
2832/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-34fd87d1-57b0-91cd-86a9-dbe88a1f32dd","timestamp":"2026-05-26T16:27:55.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:27:55 GMT","req-arrive-time":"1779812875196","req-cost-time":"322","resp-start-time":"1779812875518","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"322","x-request-id":"34fd87d1-57b0-91cd-86a9-dbe88a1f32dd"},"messages":[{"role":"assistant","content":[{"type":"text","text":"1 + 1 = 2 ✅"}]}]}

You: 请问 2+2
【==================================开始Loop_1_==================================】
正在调用工具calculator call_884f32a8bfaa45b9ae1097
【==================================看来还需要多一轮,来处理本轮"calculator"的调用结果==================================】
call_884f32a8bfaa45b9ae1097
【==================================调用工具结果2 + 2 = 4==================================】

【==================================结束Loop1==================================】
3403/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-1651bfc8-de9a-999f-b606-65edc742f770","timestamp":"2026-05-26T16:28:04.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:28:04 GMT","req-arrive-time":"1779812884032","req-cost-time":"626","resp-start-time":"1779812884658","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"626","x-request-id":"1651bfc8-de9a-999f-b606-65edc742f770"},"messages":[{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_884f32a8bfaa45b9ae1097","toolName":"calculator","input":{"expression":"2 + 2"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_884f32a8bfaa45b9ae1097","toolName":"calculator","output":{"type":"text","value":"2 + 2 = 4"}}]}]}
【==================================开始Loop_2_==================================】
2 + 2 = 4 ✅
【==================================结束Loop2==================================】
4004/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-6ab5c3e1-408f-9b4c-9782-2b4766630787","timestamp":"2026-05-26T16:28:05.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:28:05 GMT","req-arrive-time":"1779812884999","req-cost-time":"596","resp-start-time":"1779812885596","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"596","x-request-id":"6ab5c3e1-408f-9b4c-9782-2b4766630787"},"messages":[{"role":"assistant","content":[{"type":"text","text":"2 + 2 = 4 ✅"}]}]}

You: 请问 3+3
【==================================开始Loop_1_==================================】
正在调用工具calculator call_d548192e7e7b406d8505fa
【==================================看来还需要多一轮,来处理本轮"calculator"的调用结果==================================】
call_d548192e7e7b406d8505fa
【==================================调用工具结果3 + 3 = 6==================================】

【==================================结束Loop1==================================】
4641/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-c7dab953-6e76-9b95-b949-13c6c5164b05","timestamp":"2026-05-26T16:28:10.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:28:10 GMT","req-arrive-time":"1779812890329","req-cost-time":"692","resp-start-time":"1779812891021","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"692","x-request-id":"c7dab953-6e76-9b95-b949-13c6c5164b05"},"messages":[{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_d548192e7e7b406d8505fa","toolName":"calculator","input":{"expression":"3 + 3"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_d548192e7e7b406d8505fa","toolName":"calculator","output":{"type":"text","value":"3 + 3 = 6"}}]}]}
【==================================开始Loop_2_==================================】
3 + 3 = 6 ✅
【==================================结束Loop2==================================】
5308/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-83e478f1-600c-92ba-a3b1-1d716dec5bc7","timestamp":"2026-05-26T16:28:11.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:28:11 GMT","req-arrive-time":"1779812891516","req-cost-time":"284","resp-start-time":"1779812891800","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"284","x-request-id":"83e478f1-600c-92ba-a3b1-1d716dec5bc7"},"messages":[{"role":"assistant","content":[{"type":"text","text":"3 + 3 = 6 ✅"}]}]}

You: 请问一加一
【==================================开始Loop_1_==================================】
“一加一”在中文中通常指数字1 + 1,结果是 **2**。✅  
如果是其他语境(比如哲学、数学公理体系如皮亚诺算术,或网络用语),也可以进一步探讨~欢迎告诉我您的想法!😊
【==================================结束Loop1==================================】
6047/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-d893e888-3edf-911f-81b4-5e227c0c7d71","timestamp":"2026-05-26T16:28:19.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:28:19 GMT","req-arrive-time":"1779812899483","req-cost-time":"305","resp-start-time":"1779812899788","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"304","x-request-id":"d893e888-3edf-911f-81b4-5e227c0c7d71"},"messages":[{"role":"assistant","content":[{"type":"text","text":"“一加一”在中文中通常指数字1 + 1,结果是 **2**。✅  \n如果是其他语境(比如哲学、数学公理体系如皮亚诺算术,或网络用语),也可以进一步探讨~欢迎告诉我您的想法!😊"}]}]}

You: 计算 11 加 1111
【==================================开始Loop_1_==================================】
正在调用工具calculator call_8967f5a9314c48489b27b9
【==================================看来还需要多一轮,来处理本轮"calculator"的调用结果==================================】
call_8967f5a9314c48489b27b9
【==================================调用工具结果11 + 1111 = 1122==================================】

【==================================结束Loop1==================================】
6831/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-7742232d-79ad-98dd-bd8a-019aa893217f","timestamp":"2026-05-26T16:28:31.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:28:31 GMT","req-arrive-time":"1779812910963","req-cost-time":"618","resp-start-time":"1779812911582","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"618","x-request-id":"7742232d-79ad-98dd-bd8a-019aa893217f"},"messages":[{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_8967f5a9314c48489b27b9","toolName":"calculator","input":{"expression":"11 + 1111"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_8967f5a9314c48489b27b9","toolName":"calculator","output":{"type":"text","value":"11 + 1111 = 1122"}}]}]}
【==================================开始Loop_2_==================================】
11 + 1111 = 1122 ✅
【==================================结束Loop2==================================】
7659/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-2498cb1c-cf11-9de4-9ed3-d5d19198c52e","timestamp":"2026-05-26T16:28:32.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:28:31 GMT","req-arrive-time":"1779812912032","req-cost-time":"340","resp-start-time":"1779812912373","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"340","x-request-id":"2498cb1c-cf11-9de4-9ed3-d5d19198c52e"},"messages":[{"role":"assistant","content":[{"type":"text","text":"11 + 1111 = 1122 ✅"}]}]}

You: 某你
【==================================开始Loop_1_==================================】
您好,您输入的“某你”可能有误或不完整,能否请您再确认一下具体想表达的意思?比如是想提问、查询信息、计算,还是需要其他帮助?😊 我很乐意为您效劳!
【==================================结束Loop1==================================】
8549/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-136c63bd-4ebd-9932-9028-c942ee15dff7","timestamp":"2026-05-26T16:28:38.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:28:38 GMT","req-arrive-time":"1779812918509","req-cost-time":"424","resp-start-time":"1779812918933","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"424","x-request-id":"136c63bd-4ebd-9932-9028-c942ee15dff7"},"messages":[{"role":"assistant","content":[{"type":"text","text":"您好,您输入的“某你”可能有误或不完整,能否请您再确认一下具体想表达的意思?比如是想提问、查询信息、计算,还是需要其他帮助?😊 我很乐意为您效劳!"}]}]}

You: 我试试
【==================================开始Loop_1_==================================】
好的!欢迎随时尝试提问、计算、查天气,或者让我帮您写点什么、讲个故事、解个谜题…… 😊  
有什么需要,尽管说~
【==================================结束Loop1==================================】
9489/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-6e476667-28f0-99fd-a88f-83271ed4ced2","timestamp":"2026-05-26T16:28:41.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:28:41 GMT","req-arrive-time":"1779812921790","req-cost-time":"487","resp-start-time":"1779812922278","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"487","x-request-id":"6e476667-28f0-99fd-a88f-83271ed4ced2"},"messages":[{"role":"assistant","content":[{"type":"text","text":"好的!欢迎随时尝试提问、计算、查天气,或者让我帮您写点什么、讲个故事、解个谜题…… 😊  \n有什么需要,尽管说~"}]}]}

You: 挺牛
【==================================开始Loop_1_==================================】
谢谢夸奖!😄  
我会继续努力,做您靠谱又有趣的AI助手~  
有啥问题或想法,随时喊我!🚀
【==================================结束Loop1==================================】
10471/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-0f704b9a-a543-95c2-974e-074999598935","timestamp":"2026-05-26T16:28:48.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:28:48 GMT","req-arrive-time":"1779812928127","req-cost-time":"332","resp-start-time":"1779812928460","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"332","x-request-id":"0f704b9a-a543-95c2-974e-074999598935"},"messages":[{"role":"assistant","content":[{"type":"text","text":"谢谢夸奖!😄\n我会继续努力,做您靠谱又有趣的AI助手~  \n有啥问题或想法,随时喊我!🚀"}]}]}

You: 请问广州和深圳的气温差,请计算出精确的值
【==================================开始Loop_1_==================================】
要计算广州和深圳的气温差,我需要分别获取两地当前的气温(最好是同一时间的实时温度),然后相减。

目前我已知 **广州** 的天气是:小雨,**21–32°C**(这是一个温度区间,不是单一精确值)。  
但尚未获取 **深圳** 的天气数据。

✅ 我将先查询深圳当前的天气(包括实时气温),再取两地“当前气温”(如当前实测温度或中间值)进行精确计算。  
现在为您查询深圳天气:

正在调用工具get_weather call_aa5a4e7099814a36bb2dd4
【==================================看来还需要多一轮,来处理本轮"get_weather"的调用结果==================================】
call_aa5a4e7099814a36bb2dd4
【==================================调用工具结果阵雨,22-28°C,南风 2 级==================================】

【==================================结束Loop1==================================】
11611/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-d08a8cd8-f13f-97dc-b0e5-870916f9ee0a","timestamp":"2026-05-26T16:29:08.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:29:08 GMT","req-arrive-time":"1779812948387","req-cost-time":"281","resp-start-time":"1779812948668","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"280","x-request-id":"d08a8cd8-f13f-97dc-b0e5-870916f9ee0a"},"messages":[{"role":"assistant","content":[{"type":"text","text":"要计算广州和深圳的气温差,我需要分别获取两地当前的气温(最好是同一时间的实时温度),然后相减。\n\n目前我已知 **广州** 的天气是:小雨,**21–32°C**(这是一个温度区间,不是单一精确值)。  \n但尚未获取 **深圳** 的天气数据。\n\n✅ 我将先查询深圳当前的天气(包括实时气温),再取两地“当前气温”(如当前实测温度或中间值)进行精确计算。  \n现在为您查询深圳天气:\n\n"},{"type":"tool-call","toolCallId":"call_aa5a4e7099814a36bb2dd4","toolName":"get_weather","input":{"city":"深圳"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_aa5a4e7099814a36bb2dd4","toolName":"get_weather","output":{"type":"text","value":"阵雨,22-28°C,南风 2 级"}}]}]}
【==================================开始Loop_2_==================================】
感谢耐心等待!目前两地天气如下:

- **广州**:小雨,21–32°C  
- **深圳**:阵雨,22–28°C  

⚠️ 注意:气象服务通常提供的是**温度区间(最低温–最高温)**,而非单一瞬时精确值。若需“精确气温差”,需明确参考标准。常见合理做法有:

1. **按当前实测温度(理想但本接口未返回)** → 不可用;  
2. **按区间中位数近似**(常用且合理):  
   - 广州中位温度 = (21 + 32) / 2 = **26.5°C**  
   - 深圳中位温度 = (22 + 28) / 2 = **25.0°C**  
   - 温差 = |26.5 − 25.0| = **1.5°C**

✅ 因此,基于中位数的精确计算结果为:**1.5°C**(广州比深圳高约1.5°C)。

如您有特定需求(例如只比最高温/最低温,或需要实时秒级数据),欢迎告诉我,我可进一步协助! 🌤️
【==================================结束Loop2==================================】
13058/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-f7aebb65-9a3d-92df-b192-536129e8b8c8","timestamp":"2026-05-26T16:29:11.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:29:11 GMT","req-arrive-time":"1779812951773","req-cost-time":"313","resp-start-time":"1779812952087","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"313","x-request-id":"f7aebb65-9a3d-92df-b192-536129e8b8c8"},"messages":[{"role":"assistant","content":[{"type":"text","text":"感谢耐心等待!目前两地天气如下:\n\n- **广州**:小雨,21–32°C  \n- **深圳**:阵雨,22–28°C  \n\n⚠️ 注意:气象服务通常提供的是**温度区间(最低温–最高温)**,而非单一瞬时精确值。若需“精确气温差”,需明确参考标准。常见合理做法有:\n\n1. **按当前实测温度(理想但本接口未返回)** → 不可用;  \n2. **按区间中位数近似**(常用且合理):  \n   - 广州中位温度 = (21 + 32) / 2 = **26.5°C**  \n   - 深圳中位温度 = (22 + 28) / 2 = **25.0°C**  \n   - 温差 = |26.5 − 25.0| = **1.5°C**\n\n✅ 因此,基于中位数的精确计算结果为:**1.5°C**(广州比深圳高约1.5°C)。\n\n如您有特定需求(例如只比最高温/最低温,或需要实时秒级数据),欢迎告诉我,我可进一步协助! 🌤️"}]}]}

You: 计算广州和上海的温度差值!
【==================================开始Loop_1_==================================】
要计算广州和上海的温度差值,我需要先分别获取两地当前的天气(尤其是气温信息)。

✅ 我已知 **广州** 的天气:小雨,21–32°C  
但尚未查询 **上海** 的天气,现在立即为您获取:

正在调用工具get_weather call_272b5bd6a8074606ae9a86
【==================================看来还需要多一轮,来处理本轮"get_weather"的调用结果==================================】
call_272b5bd6a8074606ae9a86
【==================================调用工具结果多云,18-22°C,西南风 3 级==================================】

【==================================结束Loop1==================================】
14603/15000
🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀当前step的全部信息: {"id":"chatcmpl-4b854914-de5f-9115-b268-5d77ab97ec75","timestamp":"2026-05-26T16:29:35.000Z","modelId":"qwen-plus-latest","headers":{"content-type":"text/event-stream;charset=utf-8","date":"Tue, 26 May 2026 16:29:35 GMT","req-arrive-time":"1779812975635","req-cost-time":"315","resp-start-time":"1779812975950","server":"istio-envoy","transfer-encoding":"chunked","vary":"Origin","x-dashscope-call-gateway":"true","x-envoy-upstream-service-time":"315","x-request-id":"4b854914-de5f-9115-b268-5d77ab97ec75"},"messages":[{"role":"assistant","content":[{"type":"text","text":"要计算广州和上海的温度差值,我需要先分别获取两地当前的天气(尤其是气温信息)。\n\n✅ 我已知 **广州** 的天气:小雨,21–32°C  \n但尚未查询 **上海** 的天气,现在立即为您获取:\n\n"},{"type":"tool-call","toolCallId":"call_272b5bd6a8074606ae9a86","toolName":"get_weather","input":{"city":"上海"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_272b5bd6a8074606ae9a86","toolName":"get_weather","output":{"type":"text","value":"多云,18-22°C,西南风 3 级"}}]}]}
【==================================开始Loop_2_==================================】
已获取两地最新天气数据:

- **广州**:小雨,21–32°C → 中位温度 = (21 + 32) / 2 = **26.5°C**  
- **上海**:多云,18–22°C → 中位温度 = (18 + 22) / 2 = **20.0°C**

✅ 温差(广州 − 上海)= 26.5 − 20.0 = **6.5°C**  
(即广州当前体感温度比上海高约 **6.5 摄氏度**)

📌 补充说明:  
- 若您需要「最高温差」:32 − 22 = **10°C**  
- 「最低温差」:21 − 18 = **3°C**  
- 实际体感还受湿度、风速、日照等影响(广州湿度高,可能更闷热;上海风力稍大,体感略凉)。

如需可视化对比或导出数据,也欢迎告诉我! 😊
【==================================结束Loop2==================================】
16417/15000
💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰超出预算了!💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰💰
👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚 你该强制停止了 👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚👮🤚
{
  full: '[{"role":"user","content":"hi"},{"role":"user","content":"hi"},{"role":"assistant","content":[{"type":"text","text":"Hello! How can I assist you today?"}]},{"role":"user","content":"您好"},{"role":"assistant","content":[{"type":"text","text":"您好!有什么我可以帮您的吗?😊"}]},{"role":"user","content":"你是谁"},{"role":"assistant","content":[{"type":"text","text":"我是通义千问(Qwen),阿里巴巴集团旗下的超大规模语言模型。我可以回答问题、创作文字,比如写故事、写公文、写邮件、写剧本、逻辑推理、编程等等,还能表达观点,玩游戏等。如果您有任何问题或需要帮助,欢迎随时告诉我!😊"}]},{"role":"user","content":"哦哦,帮我查询广州的天气"},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_ca1af801d7c348beabcd82","toolName":"get_weather","input":{"city":"广州"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_ca1af801d7c348beabcd82","toolName":"get_weather","output":{"type":"text","value":"小雨,21-32°C,南风 2 级"}}]},{"role":"assistant","content":[{"type":"text","text":"广州目前天气为小雨,气温在21°C到32°C之间,风向为南风,风力2级。出门记得带伞哦!☔️"}]},{"role":"user","content":"请问 1+1"},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_18a8e6340f3341a88a9e0c","toolName":"calculator","input":{"expression":"1 + 1"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_18a8e6340f3341a88a9e0c","toolName":"calculator","output":{"type":"text","value":"1 + 1 = 2"}}]},{"role":"assistant","content":[{"type":"text","text":"1 + 1 = 2 ✅"}]},{"role":"user","content":"请问 2+2"},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_884f32a8bfaa45b9ae1097","toolName":"calculator","input":{"expression":"2 + 2"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_884f32a8bfaa45b9ae1097","toolName":"calculator","output":{"type":"text","value":"2 + 2 = 4"}}]},{"role":"assistant","content":[{"type":"text","text":"2 + 2 = 4 ✅"}]},{"role":"user","content":"请问 3+3"},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_d548192e7e7b406d8505fa","toolName":"calculator","input":{"expression":"3 + 3"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_d548192e7e7b406d8505fa","toolName":"calculator","output":{"type":"text","value":"3 + 3 = 6"}}]},{"role":"assistant","content":[{"type":"text","text":"3 + 3 = 6 ✅"}]},{"role":"user","content":"请问一加一"},{"role":"assistant","content":[{"type":"text","text":"“一加一”在中文中通常指数字1 + 1,结果是 **2**。✅ \\n如果是其他语境(比如哲学、数学公理体系如皮亚诺算术,或网络用语),也可以进一步探讨~欢迎告诉我您的想法!😊"}]},{"role":"user","content":"计算 11 加 1111"},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_8967f5a9314c48489b27b9","toolName":"calculator","input":{"expression":"11 + 1111"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_8967f5a9314c48489b27b9","toolName":"calculator","output":{"type":"text","value":"11 + 1111 = 1122"}}]},{"role":"assistant","content":[{"type":"text","text":"11 + 1111 = 1122 ✅"}]},{"role":"user","content":"某你"},{"role":"assistant","content":[{"type":"text","text":"您好,您输入的“某你”可能有误或不完整,能否请您再确认一下具体想表达的意思?比如是想提问、查询信息、计算,还是需要其他帮助?😊 我很乐意为您效劳!"}]},{"role":"user","content":"我试试"},{"role":"assistant","content":[{"type":"text","text":"好的!欢迎随时尝试提问、计算、查天气,或者让我帮您写点什么、讲个故事、解个谜题…… 😊  \\n有什么需要,尽管说~"}]},{"role":"user","content":"挺牛"},{"role":"assistant","content":[{"type":"text","text":"谢谢夸奖!😄  \\n我会继续努力,做您靠谱又有趣的AI助手~  \\n有啥问题或想法,随时喊我!🚀"}]},{"role":"user","content":"请问广州和深圳的气温差,请计算出精确的值"},{"role":"assistant","content":[{"type":"text","text":"要计算广州和深圳的气温差,我需要分别获取两地当前的气温(最好是同一时间的实时温度),然后相减。\\n\\n目前我已知 **广州** 的天气是:小雨,**21–32°C**(这是一个温度区间,不是单一精确值)。  \\n但尚未获取 **深圳**的天气数据。\\n\\n✅ 我将先查询深圳当前的天气(包括实时气温),再取两地“当前气温”(如当前实测温度或中间值)进行精确计算。  \\n现在为您查询深圳天气:\\n\\n"},{"type":"tool-call","toolCallId":"call_aa5a4e7099814a36bb2dd4","toolName":"get_weather","input":{"city":"深圳"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_aa5a4e7099814a36bb2dd4","toolName":"get_weather","output":{"type":"text","value":"阵雨,22-28°C,南风 2 级"}}]},{"role":"assistant","content":[{"type":"text","text":"感谢耐心等待!目前两地天气如下:\\n\\n- **广州**:小雨,21–32°C  \\n- **深圳**:阵雨,22–28°C  \\n\\n⚠️ 注意:气象服务通常提供的是**温度区间(最低温–最高温)**,而非单一瞬时精确值。若需“精确气温差”,需明确参考标准。常见合理做法有:\\n\\n1. **按当前实测温度(理想但本接口未返回)** → 不可用;  \\n2. **按区间中位数近似**(常用且合理):  \\n   - 广州中位温度 = (21 + 32) / 2 = **26.5°C**  \\n   - 深圳中位温度 = (22 + 28) / 2 = **25.0°C**  \\n   - 温差 = |26.5 − 25.0| = **1.5°C**\\n\\n✅ 因此,基于中位数的精确计算结果为:**1.5°C**(广州比深圳高约1.5°C)。\\n\\n如您有特定需求(例如只比最高温/最低温,或需要实时秒级数据),欢迎告诉我,我可进一步协助! 🌤️"}]},{"role":"user","content":"计算广州和上海的温度差值!"},{"role":"assistant","content":[{"type":"text","text":"要计算广州和上海的温度差值,我需要先分别获取两地当前的天气(尤其是气温信息)。\\n\\n✅ 我已知 **广州** 的天气:小雨,21–32°C  \\n但尚未查询 **上海** 的天气,现在立即为您获取:\\n\\n"},{"type":"tool-call","toolCallId":"call_272b5bd6a8074606ae9a86","toolName":"get_weather","input":{"city":"上海"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_272b5bd6a8074606ae9a86","toolName":"get_weather","output":{"type":"text","value":"多云,18-22°C,西南风 3 级"}}]}]'
}