增加成本统计信息

This commit is contained in:
锦恢 2025-04-17 16:44:39 +08:00
parent 4e8caf4c53
commit 3de7ef68ba
10 changed files with 274 additions and 25 deletions

View File

@ -35,6 +35,7 @@
## TODO
- [x] 完成最基本的各类基础设施
- [ ] chat 模式下支持进行成本分析
- [ ] 支持同时调试多个 MCP Server
- [ ] 支持通过大模型进行在线验证
- [ ] 支持 completion/complete 协议字段

View File

@ -29,7 +29,7 @@ bridge.addCommandListener('hello', data => {
function initDebug() {
connectionArgs.commandString = 'mcp run ../servers/main.py';
connectionArgs.commandString = 'uv run mcp run ../servers/main.py';
connectionMethods.current = 'STDIO';
setTimeout(async () => {

View File

@ -1,12 +1,23 @@
import { ToolItem } from "@/hook/type";
import { ref } from "vue";
import type { OpenAI } from 'openai';
type ChatCompletionChunk = OpenAI.Chat.Completions.ChatCompletionChunk;
export interface IExtraInfo {
created: number,
serverName: string,
usage?: ChatCompletionChunk['usage'];
[key: string]: any
}
export interface ChatMessage {
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
tool_call_id?: string
name?: string // 工具名称,当 role 为 tool
tool_calls?: ToolCall[]
tool_calls?: ToolCall[],
extraInfo: IExtraInfo
}
// 新增状态和工具数据

View File

@ -22,6 +22,7 @@
<div class="message-text">
<div v-if="message.content" v-html="markdownToHtml(message.content)"></div>
</div>
<MessageMeta :message="message" />
</div>
<!-- 助手调用的工具部分 -->
@ -81,6 +82,7 @@
</div>
</div>
</div>
<MessageMeta :message="message" />
</div>
</div>
@ -129,12 +131,16 @@ import { ref, onMounted, defineComponent, defineProps, onUnmounted, computed, ne
import { useI18n } from 'vue-i18n';
import { ElMessage, ScrollbarInstance } from 'element-plus';
import { tabs } from '../panel';
import { ChatMessage, ChatStorage, getToolSchema, ToolCall } from './chat';
import { ChatMessage, ChatStorage, getToolSchema, IExtraInfo, ToolCall } from './chat';
import Setting from './setting.vue';
import MessageMeta from './message-meta.vue';
// markdown.ts
import { markdownToHtml, copyToClipboard } from './markdown';
import { TaskLoop } from './task-loop';
import { ChatCompletionChunk, TaskLoop } from './task-loop';
import { llmManager, llms } from '@/views/setting/llm';
defineComponent({ name: 'chat' });
@ -174,6 +180,7 @@ interface IRenderMessage {
toolResult?: string;
tool_calls?: ToolCall[];
showJson?: Ref<boolean>;
extraInfo: IExtraInfo;
}
const renderMessages = computed(() => {
@ -182,7 +189,8 @@ const renderMessages = computed(() => {
if (message.role === 'user') {
messages.push({
role: 'user',
content: message.content
content: message.content,
extraInfo: message.extraInfo
});
} else if (message.role === 'assistant') {
if (message.tool_calls) {
@ -190,12 +198,14 @@ const renderMessages = computed(() => {
role: 'assistant/tool_calls',
content: message.content,
tool_calls: message.tool_calls,
showJson: ref(false)
showJson: ref(false),
extraInfo: message.extraInfo
});
} else {
messages.push({
role: 'assistant/content',
content: message.content
content: message.content,
extraInfo: message.extraInfo
});
}
@ -302,7 +312,11 @@ const handleSend = () => {
tabStorage.messages.push({
role: 'assistant',
content: `错误: ${msg}`
content: `错误: ${msg}`,
extraInfo: {
created: Date.now(),
serverName: llms[llmManager.currentModelIndex].id || 'unknown'
}
});
isLoading.value = false;
@ -361,7 +375,6 @@ const jsonResultToHtml = (jsonString: string) => {
return html;
};
//
const formatToolArguments = (args: string) => {
try {
const parsed = JSON.parse(args);
@ -510,9 +523,7 @@ const formatToolArguments = (args: string) => {
</style>
<style scoped>
/* 原有样式保持不变 */
/* 新增工具调用样式 */
.tool-calls {
margin-top: 10px;
}

View File

@ -0,0 +1,76 @@
<template>
<div class="message-meta" @mouseenter="showTime = true" @mouseleave="showTime = false">
<span v-if="usageStatistic" class="message-usage">
<span>
输入 {{ usageStatistic.input }}
</span>
<span>
输出 {{ usageStatistic.output }}
</span>
<span>
消耗的总 token {{ usageStatistic.total }}
</span>
<span>
缓存命中率 {{ usageStatistic.cacheHitRatio }}%
</span>
</span>
<span v-else class="message-usage">
<span>你使用的供应商暂时不支持统计信息</span>
</span>
<span v-show="showTime" class="message-time">
{{ props.message.extraInfo.serverName }} 作答于
{{ new Date(message.extraInfo.created).toLocaleString() }}
</span>
</div>
</template>
<script setup lang="ts">
import { defineComponent, defineProps, ref } from 'vue';
import { makeUsageStatistic } from './usage';
defineComponent({ name: 'message-meta' });
const props = defineProps({
message: {
type: Object,
required: true
}
});
const usageStatistic = makeUsageStatistic(props.message.extraInfo);
const showTime = ref(false);
</script>
<style scoped>
.message-meta {
margin-top: 8px;
font-size: 0.8em;
color: var(--el-text-color-secondary);
display: flex;
}
.message-time {
opacity: 0.7;
padding: 2px 6px 2px 0;
transition: opacity 0.3s ease;
}
.message-usage {
display: flex;
align-items: center;
}
.message-usage > span {
background-color: var(--el-fill-color-light);
padding: 2px 6px;
border-radius: 4px;
margin-right: 3px;
}
</style>

View File

@ -6,8 +6,8 @@ import type { OpenAI } from 'openai';
import { callTool } from "../tool/tools";
import { llmManager, llms } from "@/views/setting/llm";
type ChatCompletionChunk = OpenAI.Chat.Completions.ChatCompletionChunk;
type ChatCompletionCreateParamsBase = OpenAI.Chat.Completions.ChatCompletionCreateParams & { id?: string };
export type ChatCompletionChunk = OpenAI.Chat.Completions.ChatCompletionChunk;
export type ChatCompletionCreateParamsBase = OpenAI.Chat.Completions.ChatCompletionCreateParams & { id?: string };
interface TaskLoopOptions {
maxEpochs: number;
}
@ -18,6 +18,7 @@ interface TaskLoopOptions {
export class TaskLoop {
private bridge = useMessageBridge();
private currentChatId = '';
private completionUsage: ChatCompletionChunk['usage'] | undefined;
constructor(
private readonly streamingContent: Ref<string>,
@ -27,7 +28,9 @@ export class TaskLoop {
private onDone: () => void = () => {},
private onEpoch: () => void = () => {},
private readonly taskOptions: TaskLoopOptions = { maxEpochs: 20 },
) {}
) {
}
private async handleToolCalls(toolCalls: ToolCall[]) {
// TODO: 调用多个工具并返回调用结果?
@ -89,6 +92,13 @@ export class TaskLoop {
}
}
private handleChunkUsage(chunk: ChatCompletionChunk) {
const usage = chunk.usage;
if (usage) {
this.completionUsage = usage;
}
}
private doConversation(chatData: ChatCompletionCreateParamsBase) {
return new Promise<void>((resolve, reject) => {
@ -103,6 +113,7 @@ export class TaskLoop {
// 处理增量的 content 和 tool_calls
this.handleChunkDeltaContent(chunk);
this.handleChunkDeltaToolCalls(chunk);
this.handleChunkUsage(chunk);
this.onChunk(chunk);
}, { once: false });
@ -140,6 +151,7 @@ export class TaskLoop {
// 如果超出了 tabStorage.settings.contextLength, 则删除最早的消息
const loadMessages = tabStorage.messages.slice(- tabStorage.settings.contextLength);
userMessages.push(...loadMessages);
// 增加一个id用于锁定状态
const id = crypto.randomUUID();
@ -188,7 +200,14 @@ export class TaskLoop {
*/
public async start(tabStorage: ChatStorage, userMessage: string) {
// 添加目前的消息
tabStorage.messages.push({ role: 'user', content: userMessage });
tabStorage.messages.push({
role: 'user',
content: userMessage,
extraInfo: {
created: Date.now(),
serverName: llms[llmManager.currentModelIndex].id || 'unknown'
}
});
for (let i = 0; i < this.taskOptions.maxEpochs; ++ i) {
@ -197,6 +216,7 @@ export class TaskLoop {
// 初始累计清空
this.streamingContent.value = '';
this.streamingToolCalls.value = [];
this.completionUsage = undefined;
// 构造 chatData
const chatData = this.makeChatData(tabStorage);
@ -212,7 +232,11 @@ export class TaskLoop {
tabStorage.messages.push({
role: 'assistant',
content: this.streamingContent.value || '',
tool_calls: this.streamingToolCalls.value
tool_calls: this.streamingToolCalls.value,
extraInfo: {
created: Date.now(),
serverName: llms[llmManager.currentModelIndex].id || 'unknown'
}
});
const toolCallResult = await this.handleToolCalls(this.streamingToolCalls.value);
@ -222,14 +246,24 @@ export class TaskLoop {
tabStorage.messages.push({
role: 'tool',
tool_call_id: toolCall.id || toolCall.function.name,
content: toolCallResult
content: toolCallResult,
extraInfo: {
created: Date.now(),
serverName: llms[llmManager.currentModelIndex].id || 'unknown',
usage: this.completionUsage
}
});
}
} else if (this.streamingContent.value) {
tabStorage.messages.push({
role: 'assistant',
content: this.streamingContent.value
content: this.streamingContent.value,
extraInfo: {
created: Date.now(),
serverName: llms[llmManager.currentModelIndex].id || 'unknown',
usage: this.completionUsage
}
});
break;

View File

@ -0,0 +1,37 @@
import { IExtraInfo } from "./chat";
export interface UsageStatistic {
input: number;
output: number;
total: number;
cacheHitRatio: number;
}
export function makeUsageStatistic(extraInfo: IExtraInfo): UsageStatistic | undefined {
if (extraInfo.serverName === 'unknown' || extraInfo.usage === undefined || extraInfo.usage === null) {
return undefined;
}
const usage = extraInfo.usage;
switch (extraInfo.serverName) {
case 'deepseek':
return {
input: usage.prompt_tokens,
output: usage.completion_tokens,
total: usage.prompt_tokens + usage.completion_tokens,
cacheHitRatio: Math.ceil(usage.prompt_tokens_details?.cached_tokens || 0 / usage.prompt_tokens * 1000) / 10,
}
case 'openai':
return {
// TODO: 完成其他的数值统计
input: usage?.prompt_tokens,
output: usage?.completion_tokens,
total: usage.prompt_tokens + usage.completion_tokens,
cacheHitRatio: Math.ceil(usage.prompt_tokens_details?.cached_tokens || 0 / usage.prompt_tokens * 1000) / 10,
}
}
return undefined;
}

View File

@ -27,6 +27,8 @@ async function connectHandler(option: MCPOptions, webview: PostMessageble) {
// 比如 error: Failed to spawn: `server.py`
// Caused by: No such file or directory (os error 2)
console.log('error', error);
const connectResult = {
code: 500,
msg: (error as any).toString()

View File

@ -1,4 +0,0 @@
{
"tabs": [],
"currentIndex": -1
}

View File

@ -1,5 +1,5 @@
{
"currentIndex": 1,
"currentIndex": 2,
"tabs": [
{
"name": "资源",
@ -18,6 +18,87 @@
"storage": {
"currentPromptName": "translate"
}
},
{
"name": "交互测试",
"icon": "icon-robot",
"type": "blank",
"componentIndex": 3,
"storage": {
"messages": [
{
"role": "user",
"content": "你好,请问什么是",
"extraInfo": {
"created": 1744876735890,
"serverName": "deepseek"
}
},
{
"role": "assistant",
"content": "你好!请问你是想问什么呢?可以具体一点吗?比如:\n\n- 什么是人工智能?\n- 什么是区块链?\n- 什么是量子计算?\n- 或者其他任何你想了解的概念或问题?\n\n告诉我你的具体需求我会尽力解答",
"extraInfo": {
"created": 1744876742266,
"serverName": "deepseek",
"usage": {
"prompt_tokens": 453,
"completion_tokens": 49,
"total_tokens": 502,
"prompt_tokens_details": {
"cached_tokens": 448
},
"prompt_cache_hit_tokens": 448,
"prompt_cache_miss_tokens": 5
}
}
},
{
"role": "user",
"content": "你的名字是",
"extraInfo": {
"created": 1744878380791,
"serverName": "openai"
}
},
{
"role": "assistant",
"content": "错误: OpenAI API error: 404 404 page not found",
"extraInfo": {
"created": 1744878381940,
"serverName": "openai"
}
}
],
"settings": {
"modelIndex": 0,
"enableTools": [
{
"name": "add",
"description": "对两个数字进行实数域的加法",
"enabled": true
},
{
"name": "multiply",
"description": "对两个数字进行实数域的乘法运算",
"enabled": true
},
{
"name": "is_even",
"description": "判断一个整数是否为偶数",
"enabled": true
},
{
"name": "capitalize",
"description": "将字符串首字母大写",
"enabled": true
}
],
"enableWebSearch": false,
"temperature": 0.7,
"contextLength": 10,
"systemPrompt": ""
}
}
}
]
}