首页 / 资讯中心 / 文章详情

C#服务端AI调用成本控制:模型路由、Token管理与缓存去重实践

C#服务端AI调用成本控制:模型路由、Token管理与缓存去重实践 ★ FEATURED ARTICLE
先说一件让我印象很深的事。某个周一的早上我照常打开某云厂商的费用中心看到上个月账单里多了一笔让我愣住的开销一个内部工具类项目几乎没有外部用户AI接口费用却比前一个月翻了接近三倍。代码我基本没动只是把几个后台任务换成当时效果最强的那一类模型还顺手给每次请求加了一大段“万能系统提示词”。就这两步操作账单直接起飞。这件事之后我开始系统梳理AI调用的成本控制并把这套方法论沉淀成了C#服务端落地的一套标准做法。今天这篇文章想说清楚作为一个C#开发者当你要把一个AI能力接入.NET服务时最值得盯住的三件事是什么——模型分级路由、Token全链路管理、请求缓存与去重。不管你是刚刚开始接大模型API还是已经被账单教育过一轮这篇文章都值得你花十五分钟看完。我会把每一个策略的原理、代码实现、以及真正踩过坑之后才知道的细节都写出来。1. 失控的AI账单先看清钱是从哪条管道溜走的1.1 LLM API到底在按什么收费想控制成本第一步不是改代码而是搞清楚大模型API的计费模型。绝大多数主流服务商都按Token计费Token可以粗略理解为“字符片段”中文大约一个字对应1到2个Token。关键是输入Token和输出Token分开计价而且输出Token通常比输入Token贵一倍到三倍以上。计费项目典型价格区间示意说明输入Token每百万Token几十元到几百元不等包括系统提示词、对话历史、用户输入输出Token每百万Token一两百元到上千元不等模型生成的回复内容单价更高额外服务部分平台对函数调用、图像输入单独计价根据实际调用内容决定注意我上面写的价格只是帮助你建立量级概念不同服务商、不同模型档位的实际价格差别很大。你可以理解成每次调用大模型本质上是在对“思考原材料”和“思考产物”双重付费。1.2 账单爆炸的四个常见导火索我把自己的代码和帮别人排查的账单翻了一遍发现成本失控基本逃不出这四种情况拿旗舰模型处理所有请求。不管任务是把一句话翻译成英文还是让模型写一个复杂的分布式系统设计方案全部调用能力最强的模型。这是最普遍、也是烧钱最快的一种。上下文无节制生长。多轮对话场景里每轮都把完整历史记录重新发给模型。Token量随轮数线性增长而费用增长得更快因为每轮对话都要把所有历史Token再算一遍钱。大量重复请求并发。比如批量处理几千条商品描述其中很多数据完全一样但没有做任何去重同一个答案反复向API支付费用。完全没有监控和预警。账单是按月出的等你发现费用异常时费用已经发生了。没有实时记账就没有任何干预机会。1.3 成本控制的第一步把每次调用记成结构化日志很多C#开发者习惯用ILogger随手打一条文本日志模型、Token数、耗时都混在一个字符串里。这个习惯在排查性能问题时凑合能用但做成本分析时非常低效。我在项目里一般会定义一个独立的调用记录对象每次AI请求结束之后把关键字段写进结构化日志。这样无论是查数据库还是导入到分析工具里都能直接聚合统计。public class AiCallRecord { public string RequestId { get; set; } public string ModelId { get; set; } public string TaskCategory { get; set; } public int InputTokens { get; set; } public int OutputTokens { get; set; } public long DurationMs { get; set; } public DateTime CalledAt { get; set; } public bool CacheHit { get; set; } public int TotalTokens InputTokens OutputTokens; public decimal EstimatedCost Estimate(InputTokens, OutputTokens, ModelId); }实测心得不要试图在业务方法里手动一行行填这些字段。用装饰器模式或者管道中间件统一包装AI Client在调用前后自动记录才不容易遗漏。漏掉一次调用你的月账单分析就会缺一块而缺的往往就是最贵的那块。2. 策略一模型分级路由别让旗舰模型干杂活2.1 模型之间的成本差距不是一点半点同一个服务商旗下不同档位模型的定价差距可以到十几倍甚至几十倍。旗舰模型在复杂推理、长文本生成上确实更强但对于“判断一段评论是正面还是负面”“从文章里提取三个关键词”这类任务旗舰模型和轻量模型的结果差距微乎其微费用差距却非常大。我把常见的AI任务粗略分成三档任务类型推荐用哪档模型原因命名实体提取、关键词提取、简单文本分类轻量模型任务边界清晰轻量模型足够稳定中等长度内容生成、代码补全、格式转换主力模型质量与成本的平衡点多步推理、复杂代码生成、长文档总结旗舰模型需要更强上下文理解和推理能力有一个很容易被忽略的点轻量模型的响应速度通常更快。所以分级路由不只是省账单很多时候还能把接口延迟降下来。用户在界面上等结果的时间变短了体验反而更好。2.2 用C#实现一个配置驱动的模型路由器我推荐把模型选择做成一个独立的IModelRouter接口而不是散落在各个业务代码里。public interface IModelRouter { string ResolveModel(string taskCategory, int inputTokenEstimate 0); } public class ConfigurationModelRouter : IModelRouter { private readonly Dictionarystring, string _mapping; public ConfigurationModelRouter(IConfiguration configuration) { _mapping configuration .GetSection(AiModelRouting) .GetChildren() .ToDictionary(x x.Key, x x.Value); } public string ResolveModel(string taskCategory, int inputTokenEstimate 0) { // 先从配置里精确匹配 if (_mapping.TryGetValue(taskCategory, out var model)) { // 可针对超长输入额外升级 if (inputTokenEstimate 8000 model low-cost) { return _mapping.GetValueOrDefault(upgrade, model); } return model; } // 未知任务类型默认返回主力模型保证质量下限 return _mapping.GetValueOrDefault(default, main-model); } }对应的appsettings.json配置大概长这样{ AiModelRouting: { entity-extraction: low-cost, keyword-extract: low-cost, sentiment-analysis: low-cost, content-summary: main-model, code-review: main-model, complex-reasoning: flagship, default: main-model, upgrade: main-model } }这样做的价值在于换模型不需要改业务代码只改配置。当某个轻量模型升级换代、或者账单压力变大时运维直接改配置、重新发布就能立刻切换。2.3 分级路由最大的坑误判刚开始做分级路由时我踩过一个典型的坑把所有文本分类任务都丢给轻量模型结果有一个分类任务包含大量专业术语轻量模型频繁理解出错产出的结果需要人工返工隐性成本高得惊人。后来我总结出一条经验给路由设计一个“失败升级”兜底机制。如果业务对结果质量敏感可以在调用层加一个校验例如轻量模型输出不合规时自动用同一份请求再调一次主力模型。虽然这会导致偶尔出现一次双倍费用但避免了质量事故导致的更大损失。public async Taskstring ExecuteWithFallbackAsync( string taskCategory, Funcstring, Taskstring invokeModelAsync, Funcstring, bool validateResult) { var initialModel _router.ResolveModel(taskCategory); var result await invokeModelAsync(initialModel); if (!validateResult(result)) { var fallbackModel _router.ResolveModel(complex-reasoning); return await invokeModelAsync(fallbackModel); } return result; }实操提醒不要把成本策略和质量完全对立。分级路由的目标是“把每一分钱花在匹配任务难度的地方”而不是“追求绝对最便宜”。给低风险、非关键路径分配便宜模型给核心业务保留质量兜底才是可持续的做法。3. 策略二Token预算管理从提示词到上下文全链路省法3.1 双向计费里的隐蔽损耗很多新手只盯模型单价忽略了一个事实提示词里每一个字都是要花钱的输入Token。而且输入Token在每个请求里都会重新计算一次。如果一个系统提示词有500个Token每天调用一万次就是每天500万输入Token。这只是“背景介绍”部分还没算真正的用户输入。输出Token就更值得警惕。有些模型在回答时会把思考过程也吐出来如果业务场景不需要展示思考过程这些Token就属于“为质量溢价买单”。所以输出侧的预算控制经常比输入侧更能立竿见影。3.2 给提示词瘦身用最短的表达换取稳定输出我见过很多团队的提示词写得像作文动辄七八百字里面真正约束模型行为的关键规则只有两百字。剩下的大段内容模型不一定用得上但钱一分没少扣。举个最简单的对比。改造前你是一个专业的文本处理助手。你要负责对用户输入的中文文本进行情感分析。 请你仔细阅读输入的文本思考其中表达的积极情绪、消极情绪或中性情绪。 你的回答必须是一个JSON对象包含两个字段第一个字段是sentiment表示情感倾向 取值范围是positive、negative、neutral。第二个字段是confidence表示置信度取值范围0到1。 请确保你的输出不要包含任何其他内容不要输出解释不要输出思考过程。改造后分析文本情感只输出JSON{sentiment:positive|negative|neutral,confidence:0-1}两者效果几乎一样但Token用量可能差三到五倍。提示词瘦身的核心思路是只保留“约束输出格式”和“限定任务边界”的内容删掉所有“塑造人格”和冗余解释。经验之谈在配置系统提示词时可以给每条规则编号。遇到模型输出不稳定直接指出“按规则3执行”比重新写一大段提示词便宜得多也更容易定位问题。3.3 上下文窗口控制该忘就忘多轮对话场景例如客服问答、调试助手最容易出现上下文无限膨胀。每一轮都把全部历史发送给模型Token量随轮数线性增长费用也是线性增长。一个简单有效的方案是滑动窗口维护最近N轮public class SlidingWindowContext { private readonly int _maxRounds; private readonly ListChatMessage _messages new(); public SlidingWindowContext(int maxRounds) { _maxRounds maxRounds; } public void AddMessage(ChatMessage message) { _messages.Add(message); int currentRounds _messages.Count(m m.Role user); if (currentRounds _maxRounds) { // 移除最早的一轮用户消息和助手消息 int firstUserIndex _messages.FindIndex(m m.Role user); if (firstUserIndex 0) { _messages.RemoveAt(firstUserIndex); if (firstUserIndex _messages.Count) { _messages.RemoveAt(firstUserIndex); } } } } public IReadOnlyListChatMessage GetMessages() _messages; }更进一步的做法是“摘要压缩”当历史超过N轮用一次轻量模型调用把之前的对话浓缩成一段摘要之后只把摘要和最近几轮发给主力模型。这相当于给上下文做了一个有损压缩费用省得最明显但要注意摘要会丢失一些细节不适合对信息完整性要求极高的场景。3.4 用max_tokens和采样参数控制输出规模在发起请求的时候最好显式设置MaxTokens上限。否则遇到某些模型“话痨”模式它可能输出远超预期的长度而输出Token又是按高价计费。另一个容易被忽视的参数是温度temperature。把温度调到0到0.3之间可以让输出更稳定同时减少无意义的发散内容很多时候也能压缩输出长度。如果你只是做分类、提取这类任务温度越低越省钱。var request new ChatCompletionRequest { Model modelId, Messages messages, MaxTokens 300, Temperature 0.2, };注意流式传输SSE只改变用户等待的体验不改变计费总量。模型该输出多少Token还是按多少Token计费。所以别为了省费用而强制关闭流式那是两回事。4. 策略三请求缓存与去重让同一份答案只付一次钱4.1 什么业务适合做AI请求缓存不是所有AI调用都适合缓存。适合缓存的任务有几个特征输入重复率高、输出要求一致、对实时性不敏感。举例来说商品描述自动生成、FAQ问答库、政策条款解释、批量文本摘要。这些场景里同一段输入出现多次概率很高。比如一个电商平台有一万件商品其中很多商品共享相似的属性描述如果每次都让模型重新生成一遍钱就浪费得毫无价值。而不适合缓存的包括需要结合当前私有数据实时回答的问题、依赖用户个性化上下文的对话、以及结果预期会随模型升级而变化的场景。4.2 从完全匹配缓存到语义缓存最朴素的缓存方式是完全匹配用请求参数算一个Key查字典命中就直接返回。public class PromptCache { private readonly ConcurrentDictionarystring, CacheEntry _cache new(); public string GetOrAdd(string cacheKey, Funcstring computeResult, TimeSpan ttl) { if (_cache.TryGetValue(cacheKey, out var entry)) { if (DateTime.UtcNow - entry.CreatedAt ttl) { return entry.Result; } _cache.TryRemove(cacheKey, out _); } var result computeResult(); _cache.TryAdd(cacheKey, new CacheEntry(result, DateTime.UtcNow)); return result; } }但这个方案的问题很直观用户把“帮我解释一下什么是依赖注入”和“依赖注入是什么”打成两个请求完全匹配缓存就无法命中。这时候就需要语义缓存。语义缓存的思路是把输入文本转成向量算向量之间的相似度相似度高就当作同一请求返回缓存结果。.NET生态里有不少本地向量计算库可以用甚至可以直接调用轻量模型的Embedding接口来算向量。实际使用中我给一个建议语义缓存只适合结果不敏感的通用问答。如果业务包含用户ID、订单号这类个性化字段一定要先把这些字段排除在“语义相似度比较”之外否则把A用户的缓存结果返回给B用户就是重大事故。4.3 缓存过期与一致性版本号是救命稻草缓存最隐蔽的坑是“模型升级后缓存不失效”。比如你用旧模型生成了一批FAQ答案缓存有效期30天结果第10天模型服务商发布了更强的新版本你希望答案跟随新模型能力提升但缓存还在给用户派发旧答案。解决办法很简单把系统提示词版本号和模型版本号一起放进缓存Key里。string BuildCacheKey(string taskCategory, string userInput, string promptVersion, string modelId) { return ${taskCategory}:{promptVersion}:{modelId}:{Normalize(userInput)}; }这样每次调整提示词或者切换模型缓存自动错开不会出现新旧数据混用。这个细节看似不起眼但是我实际排查过的生产事故里有不少就是因为忘了做这个处理。4.4 命中率调优的小技巧如果你想在短期内提升命中率可以从三个方向入手输入标准化对用户输入做统一处理例如去掉全角半角差异、大小写统一、去掉多余空格。这样原本“长得差不多”的文本可以走完全匹配缓存。分批任务去重批量处理场景先对全部输入做一次分组去重相同内容只调用一次AI再按原顺序回填结果。这一步几乎零成本但能直接砍掉大量重复费用。缓存层级拆分把系统提示词和用户输入分开缓存因为系统提示词都是重复的。很多缓存方案把整段请求混在一起做Key等于把不变量也放进了变量部分白白拉低命中率。5. 工程化落地预算监控、预警与熔断三件套5.1 给每次调用打上成本标签前三章讲的是“省钱”这一章讲的是“看住钱”。没有监控的成本策略等于闭着眼睛开车。我在生产环境里会给每个业务功能分配一个FeatureTag例如order-pipeline、customer-support。AI调用中间件把FeatureTag记录在用量的结构化日志里成本分析就能按功能聚合。public class CostTrackingMiddleware { private readonly FuncAiRequest, TaskAiResponse _next; public async TaskAiResponse InvokeAsync(AiRequest request) { var sw Stopwatch.StartNew(); var response await _next(request); sw.Stop(); var record new AiCallRecord { RequestId Guid.NewGuid().ToString(N), ModelId request.ModelId, TaskCategory request.TaskCategory, InputTokens response.Usage.InputTokens, OutputTokens response.Usage.OutputTokens, DurationMs sw.ElapsedMilliseconds, CalledAt DateTime.UtcNow, CacheHit request.FromCache, }; // record 写入结构化日志 return response; } }5.2 预算阈值与动态降级最理想的做法是在账单还没产生之前就看到今天的费用预估。因为Token计费是实时的每笔请求返回的Usage就带着准确Token数完全可以在内存里做累计然后按当前累计值和日预算的比值决定后续策略。我常用的一套策略是当日已用金额占日预算比例策略动作低于 50%正常调用50% - 80%所有非核心任务的模型档位降一级关闭可选的重试80% - 100%非核心任务改走本地规则引擎或直接拒绝超过 100%核心任务降至主力模型旗舰模型仅保留最高优先级使用这个策略用代码实现并不复杂本质上就是一个带状态的预算检查器。重点是策略要可配置、可热更新否则每次调整预算都要发版本运维会崩溃。5.3 熔断与限流防止故障导致费用雪崩有一个特别容易被忽视的场景你的上游服务出现异常批量任务疯狂重试每次重试都会再次调用AI。这时候成本不是线性增长而是指数级增长。所以熔断不只是稳定性问题更是成本问题。我推荐在AI调用层做两层保护限流器控制每秒最大的并发请求数。比如某批量任务平时每秒最多调用20次限流值设为40超过就排队或丢弃。熔断器连续失败超过N次或者按错误码判断服务不可用直接打开熔断快速返回失败而不是继续击穿下游。public class CircuitBreaker { private readonly int _failureThreshold; private int _consecutiveFailures; private bool _isOpen; public bool IsOpen() _isOpen; public void OnSuccess() { _consecutiveFailures 0; _isOpen false; } public void OnFailure() { _consecutiveFailures; if (_consecutiveFailures _failureThreshold) { _isOpen true; } } }熔断还有一个好处当AI服务真的出问题时你和用户都知道系统正在降级而不会默默地把账单刷出新高度。6. 把三大策略整合进C#服务实测与踩坑记录6.1 一个整合链路的设计思路把前面三个策略串起来完整的调用链应该是这样请求进入AI服务门面Facade先打上TaskCategory和FeatureTag。门面把请求交给预算检查器判断当前预算状态是否符合调用条件。通过预算检查后查缓存。命中就直接返回不进入模型调用。缓存未命中由模型路由器决定用哪个模型。发起调用中间件自动记录Token用量和耗时更新当日累计预算。返回结果前把结果写入缓存注意缓存Key包含提示词版本和模型版本。public class AiServiceFacade { private readonly IModelRouter _router; private readonly PromptCache _cache; private readonly BudgetGuard _budgetGuard; private readonly CircuitBreaker _breaker; public async TaskAiResponse ExecuteAsync(AiRequest request) { if (_breaker.IsOpen()) throw new ApplicationException(AI服务熔断中请稍后重试); if (!_budgetGuard.CanInvoke(request.TaskCategory, out var degradeMode)) throw new ApplicationException(当前预算额度不足已停止非核心调用); // 尝试命中缓存 var cacheKey BuildCacheKey(request, _router.ResolveModel(request.TaskCategory)); var cached _cache.Get(cacheKey); if (cached ! null) return cached; // 按预算状态决定是否降级 if (degradeMode BudgetDegradeMode.LowCostOnly) { request.ForceLowCostModel true; } try { var model request.ForceLowCostModel ? low-cost : _router.ResolveModel(request.TaskCategory); var response await InvokeModelAsync(model, request); _breaker.OnSuccess(); _cache.Set(cacheKey, response, TimeSpan.FromMinutes(30)); return response; } catch { _breaker.OnFailure(); throw; } } }6.2 实测效果用数据说话我在一个模拟的批量内容审核项目中跑过一组对照。场景是5000条短文本分类每条文本平均120字全部用默认配置直接调用旗舰模型对照组套用“轻量模型完全匹配缓存批内去重”。结果差异很直观方案预估输入Token预估输出Token估算费用示意价格全部走旗舰模型约420万约100万相对较高分级路由批内去重约180万约48万降低约60%分级路由批内去重缓存约95万约30万再降约35%这组数据按真实市场价格折算可能不精确但量级足以说明问题分级路由首先把单价降下来去重把调用次数降下来缓存把高频重复请求挡在计费之外。三者叠加费用常常能省到原来的五分之一以下。6.3 我在生产里踩过的三个坑第一缓存Key里没放提示词版本号。上线新提示词之后A/B测试效果始终不对排查半天才发现是旧缓存还在命中。加上版本号之后一切恢复正常。这个坑提醒我缓存设计一定要先考虑“失效条件”再考虑“命中效率”。第二路由分类做得太激进。刚开始为了省钱我把“代码审查”也划到轻量模型。结果轻量模型给出的审查意见质量偏低团队同学反馈“这个工具不太聪明”。后来把代码审查改回主力模型把轻量模型的角色限定在“拼写检查、格式规范化”这类边界清晰的任务上效果和成本才达到平衡。第三忘记关闭调试日志里的完整请求内容。有些日志框架默认会记录请求体如果日志级别切到Debug系统提示词连同用户的完整输入都会被打到日志中心。这不只是费用问题更是数据合规风险。建议默认只记录Token用量和耗时完整请求内容只在本地调试时开启。综合来看AI成本控制不是某一个技巧的功劳而是一套围绕“路由、Token、缓存、监控、熔断”建立的工程习惯。当我回看那次账单翻倍的经历真正的问题不是我选错了模型而是我的系统没有任何一层机制去约束“谁该用贵模型”“每次该花多少Token”“同一请求能不能复用”。这三件事都做扎实了账单自然不会失控。最后分享一个我最近养成的习惯每次Code Review涉及AI调用我都会问三个问题——这次请求真的需要大模型吗这个模型是不是任务所需的最强档同样的结果能不能直接从缓存里拿如果团队的每一行AI相关代码都能回答清楚这三个问题成本控制就不再是某个人的KPI而是团队默认的工程素养。
阅读完成 · 觉得有帮助?
咨询建站