ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Agent从Demo到生产:工具调用、Token、并发与可观测性四道坎

Agent从Demo到生产:工具调用、Token、并发与可观测性四道坎 1. 从Demo到生产Agent落地为什么总在同一个地方翻车我见过太多团队在Agent项目上经历同一条曲线第一周Demo跑通全员兴奋第二周开始接真实业务问题冒头第三周上线用户投诉第四周项目进入维护模式实际上就是没人敢动。这个曲线不是偶然它背后有一套非常稳定的结构性原因。先说一个我自己的经历。去年帮一个做企业知识库的团队看他们的Agent系统Demo阶段用LangGraph搭了一个多工具调用的流程本地跑得飞起工具调用准确率目测90%以上。上线第一天客服部门反馈答非所问第二天反馈工具调错了第三天直接反馈系统卡死。我去看日志发现三个问题同时爆发工具调用的Schema在并发场景下被污染、Token用量在长对话里指数级膨胀、LLM返回的JSON格式在压力下开始漂移。这三个问题在Demo阶段都不会出现因为Demo的输入是精心构造的、并发是1、对话轮次是3到5轮。生产环境的输入是脏的、并发是几十上百、对话轮次可能到50轮以上。Demo和生产之间的鸿沟不是模型能力的鸿沟是工程约束的鸿沟。这篇文章我想把这件事讲透。不是讲Agent是什么这种入门内容而是讲一个已经跑通Demo的团队在往生产走的时候会撞上哪四道坎每道坎的根因是什么以及我实际用过的工程解法。关键词里的Agent、LLM、工具调用、Schema、Token这几个词基本就是这四道坎的核心。适合谁看如果你正在做Agent项目Demo已经跑通但上线后效果不稳定或者你正准备从Demo往生产推想提前知道坑在哪又或者你是技术负责人需要评估Agent项目的工程复杂度——这篇内容应该能帮你省掉至少两到三周的试错时间。我先把四道坎的结论摆出来后面逐个拆坎表面症状根因核心解法方向第一道工具调用不稳定工具选错、参数错、时好时坏Schema定义与LLM理解之间的语义鸿沟Schema分层设计工具描述工程第二道Token失控成本飙升、响应变慢、上下文溢出上下文无策略增长工具返回未裁剪Token预算制上下文压缩策略第三道并发下的状态污染偶发错误、难以复现、日志对不上共享状态无隔离的Agent实例会话隔离状态快照幂等设计第四道可观测性缺失出了问题不知道哪一步错链路追踪缺失日志粒度粗全链路Trace结构化日志回放机制这四道坎不是独立的它们会互相放大。Token失控会让LLM注意力分散进而导致工具调用更不稳定并发状态污染会让可观测性彻底失效因为你连复现都做不到。所以解法也必须是系统性的不能只修一个点。2. 第一道坎工具调用在Demo里很准上线就飘2.1 工具调用不稳定的真实根因不是模型笨很多人遇到工具调用不稳定第一反应是换个更强的模型。我试过从某个中等模型换到顶级模型Demo阶段准确率从88%提到94%但上线后依然会飘。为什么因为问题不在模型的推理能力而在Schema的语义清晰度和工具描述的歧义性。LLM做工具调用本质是一个语义匹配过程它读你的工具描述和参数Schema然后判断当前用户意图该匹配哪个工具、参数该怎么填。这个过程里任何歧义都会被放大。比如你有一个工具叫search_knowledge描述写的是搜索知识库另一个工具叫query_database描述写的是查询数据库。用户问帮我查一下上个月的销售数据LLM该选哪个如果知识库里也有销售数据它可能选错。我在实际项目里做过一个统计工具调用错误中约60%是选错工具30%是参数填错只有10%是模型完全理解不了。选错工具和参数填错根因都是Schema和描述的问题不是模型的问题。2.2 Schema分层设计把给LLM看的和给代码用的分开这是我最想分享的一个工程实践。很多团队写工具Schema直接用代码里的类型定义比如用Zod schema或者JSON Schema然后把这个Schema直接塞给LLM。这会导致一个问题代码需要的字段和LLM需要理解的字段粒度不一样。代码可能需要一个user_id字段类型是string格式是UUID。但LLM在调用时它不知道当前用户的UUID是什么它只知道当前用户。如果你把user_id作为必填参数暴露给LLMLLM就会瞎编一个UUID或者从上下文里抓一个错的。我的做法是Schema分层LLM层Schema只暴露LLM能理解的参数用自然语言描述参数尽量少而语义明确。执行层Schema代码实际需要的完整参数由中间层从LLM层参数会话上下文补全。举个例子一个查询订单的工具# LLM层Schema给LLM看的 { name: query_order, description: 根据订单号或用户当前会话查询订单信息。如果用户说我的订单不需要传order_id系统会自动使用当前登录用户的身份。, parameters: { order_id: { type: string, description: 订单号如果用户没有明确提供订单号留空 }, query_type: { type: string, enum: [by_id, by_current_user, by_date_range], description: 查询类型按订单号查、按当前用户查、按日期范围查 } } }# 执行层Schema代码实际用的 { user_id: 从会话上下文注入, order_id: 从LLM参数或上下文获取, date_range: 从LLM参数获取, tenant_id: 从会话上下文注入 }中间层做一件事把LLM层参数和会话上下文合并补全执行层需要的字段。这样LLM不需要知道user_id、tenant_id这些它根本不该知道的字段减少了参数填错的空间。提示Schema分层的关键原则是LLM只负责它擅长的语义判断系统负责它擅长的数据补全。不要让LLM做它做不好的事。2.3 工具描述工程用三个点消除歧义热词里有一个很有意思的说法LLM的token三个点——key我是谁、query我在找什么、value我能提供什么。这个框架其实可以直接用在工具描述上。每个工具的描述应该让LLM能回答三个问题我是谁这个工具是干什么的它的能力边界在哪。我在找什么调用这个工具需要什么输入输入的语义是什么。我能提供什么这个工具返回什么返回值的语义是什么。我见过很多工具描述只写了第一点比如搜索知识库。这不够。好的描述应该像这样工具名search_knowledge_base 描述在企业内部知识库中搜索文档。适用于回答公司政策是什么、流程怎么走、产品文档在哪这类问题。不适用于查询实时数据如库存、订单状态实时数据请用query_realtime_data工具。 输入query搜索关键词用自然语言描述你要找什么top_k返回条数默认5 输出返回文档片段列表每个片段包含标题、内容摘要、相关度分数。这个描述里我是谁搜索知识库、我在找什么query和top_k、我能提供什么文档片段列表都齐了还额外加了不适用于什么来划边界。实测下来这种描述能把工具选错率降低一半以上。2.4 参数校验与重试给LLM一次改作业的机会即使Schema和描述都做好了LLM还是可能填错参数。这时候不要直接报错给用户而是给LLM一次重试机会。我的做法是在工具调用层加一个参数校验错误反馈重试机制def call_tool_with_retry(tool_name, params, llm_client, max_retries2): for attempt in range(max_retries): try: validated_params validate_params(tool_name, params) return execute_tool(tool_name, validated_params) except ValidationError as e: if attempt max_retries - 1: raise # 把校验错误反馈给LLM让它重新生成参数 params llm_client.regenerate_params( tool_nametool_name, original_paramsparams, error_messagestr(e) )这个机制的关键是错误信息要具体。不要只说参数错误要说order_id格式不对应该是纯数字你给的是ABC123。LLM看到具体错误修正的成功率很高。我实测的数据不加重试工具调用最终失败率约8%加一次重试降到3%加两次重试降到1.5%左右。但重试会增加Token消耗和延迟所以max_retries设2就够了不要设太多。3. 第二道坎Token用量在长对话里悄悄吃掉你的利润3.1 Token失控的三个隐蔽来源Token问题最坑的地方在于Demo阶段你根本感觉不到。3到5轮对话每轮几千Token成本可以忽略。但生产环境里Token会从三个地方悄悄涨起来第一个来源对话历史无策略增长。很多Agent实现是把完整对话历史每次都塞进上下文。第1轮1000 Token第10轮可能就10000 Token第50轮可能50000 Token。而且每一轮都要重新处理全部历史成本是O(n²)增长的。第二个来源工具返回结果未裁剪。你调一个搜索工具返回了20条文档每条500字一共10000字。这些内容全塞进上下文但LLM可能只需要其中2条。剩下的8000字就是纯浪费。第三个来源系统提示词和工具Schema的固定开销。如果你的系统提示词写了3000字工具Schema有20个工具每个工具描述200字那每轮对话的固定开销就是7000 Token。50轮下来光固定开销就是350000 Token。我算过一笔账一个中等规模的Agent应用日活1000用户平均每人10轮对话如果不做Token优化日Token消耗可能在5000万到1亿之间。按当前主流模型的价格这是一笔不小的成本。优化后能降到三分之一甚至更低。3.2 Token预算制给每次LLM调用设一个天花板我的核心解法是Token预算制。每次调用LLM之前先算一下这次调用最多能用多少Token然后按预算来组装上下文。具体做法class TokenBudget: def __init__(self, model_context_window, reserve_for_output2000): self.total model_context_window self.reserve reserve_for_output self.available self.total - self.reserve def allocate(self, system_prompt, tools_schema, history, current_query): # 固定开销 fixed count_tokens(system_prompt) count_tokens(tools_schema) # 当前查询必须保留 query_tokens count_tokens(current_query) # 剩余给历史 history_budget self.available - fixed - query_tokens if history_budget 0: # 固定开销就超了需要裁剪系统提示词或工具 raise BudgetExceeded(固定开销超出预算) # 按预算裁剪历史 trimmed_history trim_history(history, history_budget) return system_prompt, tools_schema, trimmed_history, current_query这个预算制的关键参数是reserve_for_output。你要给LLM的输出留够空间否则它可能生成到一半被截断。我一般留2000 Token给输出如果工具调用参数比较复杂留3000。3.3 上下文压缩不是简单截断是分层保留裁剪历史不能简单地从最早的消息开始删那样会丢掉关键信息。我用的是分层保留策略最近N轮完整保留N一般取3到5。较早的对话保留摘要不保留原文。摘要由LLM生成或者用规则提取关键实体和意图。工具调用结果只保留最近一次调用的结果更早的只保留调用了什么工具、得到了什么结论的摘要。def trim_history(history, budget): # 最近3轮完整保留 recent history[-3:] recent_tokens count_tokens(recent) if recent_tokens budget: # 最近3轮就超了只能保留最近1轮 return history[-1:] # 剩余预算给摘要 remaining budget - recent_tokens older history[:-3] summary summarize_history(older, max_tokensremaining) return [summary] recent摘要的质量很关键。我试过两种方式一种是用LLM生成摘要质量好但额外消耗Token一种是用规则提取提取用户提到的实体、意图、已确认的信息质量稍差但不消耗Token。实际项目里我倾向于混合对关键轮次用LLM摘要对普通轮次用规则提取。3.4 工具返回裁剪让工具自己报重点工具返回结果的裁剪最好在工具层做而不是在Agent层做。因为工具自己最清楚哪些字段是重要的。我的做法是给每个工具定义一个summary_fields配置工具返回时同时返回完整结果和摘要结果def search_knowledge_base(query, top_k5): results vector_search(query, top_k) full_results [{title: r.title, content: r.content, score: r.score} for r in results] # 摘要只保留标题和分数内容截断到200字 summary_results [{title: r.title, content: r.content[:200], score: r.score} for r in results] return { full: full_results, summary: summary_results, summary_tokens: count_tokens(summary_results) }Agent层根据当前Token预算决定用full还是summary。如果预算充足用full如果紧张用summary。这样既保证了信息完整性又控制了Token。注意工具返回裁剪不要裁得太狠否则LLM可能因为信息不足而做出错误判断。我的经验是摘要结果至少要保留能支撑LLM做下一步决策的最小信息量。4. 第三道坎并发一上来状态就乱了4.1 并发状态污染的典型症状这个问题在Demo阶段完全不会出现因为Demo是单用户、单会话、串行执行。但生产环境是多用户、多会话、并发执行。如果你的Agent实现里有任何共享状态并发一上来就会出问题。我遇到过最典型的一个案例一个团队用全局变量存当前会话的上下文单用户测试没问题上线后10个用户同时用A用户的对话历史被B用户看到了。这不是段子是真实发生的事故。并发状态污染的症状通常有这几个偶发错误难以复现日志里看一切正常。用户A收到了用户B的响应。同一个会话里前后两轮对话的上下文对不上。工具调用参数里出现了不属于当前会话的数据。这些症状的共同根因是Agent实例或状态被多个会话共享了。4.2 会话隔离每个会话一个独立的状态容器最直接的解法是会话隔离。每个会话session创建独立的Agent实例或状态容器会话之间不共享任何可变状态。class AgentSession: def __init__(self, session_id, user_id): self.session_id session_id self.user_id user_id self.history [] self.state {} self.created_at time.time() def process(self, user_input): # 所有状态操作都在这个实例内 self.history.append({role: user, content: user_input}) response self._run_agent() self.history.append({role: assistant, content: response}) return response # 会话管理器 class SessionManager: def __init__(self): self.sessions {} def get_session(self, session_id, user_id): if session_id not in self.sessions: self.sessions[session_id] AgentSession(session_id, user_id) return self.sessions[session_id]这个方案的关键是任何可变状态都必须挂在会话实例上不能挂在类上或全局。我见过有人把工具调用缓存挂在类变量上结果所有会话共享缓存A会话的缓存被B会话命中返回了错误结果。4.3 状态快照与恢复让会话可以暂停和继续生产环境里会话可能跨越很长时间。用户上午问了一半下午接着问。如果你的Agent状态只存在内存里服务重启就丢了。所以需要状态快照。我的做法是每次会话状态变更后异步写一份快照到Redis或数据库def save_snapshot(session): snapshot { session_id: session.session_id, user_id: session.user_id, history: session.history[-20:], # 只存最近20轮 state: session.state, updated_at: time.time() } redis.setex( fagent:session:{session.session_id}, 3600, # 1小时过期 json.dumps(snapshot) )恢复时如果内存里没有会话就从Redis加载。这样服务重启或会话迁移都不会丢状态。但这里有个坑快照的序列化要小心。如果state里存了不可序列化的对象比如数据库连接、文件句柄序列化会失败。我的原则是state里只存纯数据dict、list、str、int不存对象。4.4 幂等设计防止重复执行工具调用并发场景下还有一个隐蔽问题同一个工具调用可能被执行两次。比如用户网络抖动前端重试了请求或者Agent内部重试机制触发了重复调用。如果工具不是幂等的比如创建订单就会出问题。解法是给每个工具调用生成一个幂等键idempotency key工具执行前先检查这个键是否已经执行过def execute_tool_idempotent(tool_name, params, idempotency_key): # 检查是否已执行 cached redis.get(ftool:idempotent:{idempotency_key}) if cached: return json.loads(cached) # 执行工具 result execute_tool(tool_name, params) # 缓存结果24小时过期 redis.setex( ftool:idempotent:{idempotency_key}, 86400, json.dumps(result) ) return result幂等键的生成规则session_id tool_name hash(params) 轮次。这样同一个会话里同一个工具用同样参数调用只会执行一次。5. 第四道坎出了问题你根本不知道哪一步错了5.1 可观测性缺失的代价Agent系统的可观测性比传统后端系统难做因为它的执行链路是动态的LLM决定调哪个工具、工具返回什么、LLM再决定下一步。这条链路不是代码写死的是运行时生成的。如果你没有全链路追踪出了问题只能靠猜。我经历过一次线上事故用户反馈Agent答错了。我去看日志只看到用户输入XAgent输出Y中间过程全丢了。我不知道LLM当时看到了什么上下文、调了哪个工具、工具返回了什么、LLM为什么做出那个决策。最后花了3个小时才定位到是一个工具返回了过期数据。如果当时有全链路Trace这个问题5分钟就能定位。5.2 全链路Trace记录每一次LLM调用和工具调用我的做法是给每个会话的每一轮对话生成一个Trace ID然后记录这条链路上的所有关键事件class AgentTracer: def __init__(self, trace_id): self.trace_id trace_id self.events [] def log_llm_call(self, model, input_tokens, output_tokens, prompt, response): self.events.append({ type: llm_call, timestamp: time.time(), model: model, input_tokens: input_tokens, output_tokens: output_tokens, prompt: prompt, # 可以脱敏后存储 response: response }) def log_tool_call(self, tool_name, params, result, duration_ms): self.events.append({ type: tool_call, timestamp: time.time(), tool_name: tool_name, params: params, result: result, duration_ms: duration_ms }) def log_decision(self, decision, reason): self.events.append({ type: decision, timestamp: time.time(), decision: decision, reason: reason })这些事件写入结构化日志JSON格式然后送到日志系统如Elasticsearch或ClickHouse。出问题时用Trace ID一查整条链路清清楚楚。5.3 结构化日志的字段设计日志字段设计很关键设计不好查起来还是费劲。我用的字段集字段说明示例trace_id链路追踪IDsess_abc123_turn_5session_id会话IDsess_abc123user_id用户IDuser_456turn对话轮次5event_type事件类型llm_call / tool_callmodel模型名gpt-4input_tokens输入Token数3500output_tokens输出Token数200tool_name工具名search_knowledge_baseduration_ms耗时1200status状态success / errorerror_message错误信息timeout有了这些字段你可以做很多分析哪个工具最慢、哪个模型Token消耗最高、哪类问题最容易出错、错误率随时间的变化趋势。5.4 回放机制把线上问题搬到本地复现可观测性的终极形态是回放。把线上一次出问题的完整链路输入、上下文、工具调用、LLM响应录下来在本地重放逐步调试。class AgentReplayer: def __init__(self, trace_events): self.events trace_events self.cursor 0 def replay(self): for event in self.events: if event[type] llm_call: # 用录制的prompt重新调用LLM对比响应 new_response call_llm(event[prompt]) print(f原始响应: {event[response]}) print(f重放响应: {new_response}) elif event[type] tool_call: # 用录制的参数重新调用工具对比结果 new_result execute_tool(event[tool_name], event[params]) print(f原始结果: {event[result]}) print(f重放结果: {new_result})回放机制的价值在于它把偶发问题变成了可复现问题。很多并发状态污染的问题在回放时能稳定复现因为回放是串行的状态是干净的。如果回放时问题消失了那基本可以确定是并发状态污染。6. 四道坎的联动为什么不能只修一个点6.1 Token优化和工具调用稳定性的相互影响这两道坎是联动的。Token失控会导致上下文里塞了太多无关信息LLM的注意力被分散工具调用准确率下降。反过来工具调用不稳定会导致重试重试会增加Token消耗。我实测过一个数据在同一个Agent系统上只做Token优化上下文压缩工具返回裁剪工具调用准确率从82%提升到89%。为什么因为上下文干净了LLM更容易聚焦在当前任务上。所以我的建议是先做Token优化再做工具调用优化。Token优化是基础它能让后续的优化事半功倍。6.2 并发隔离和可观测性的相互依赖并发状态污染和可观测性也是联动的。如果状态没隔离日志里会出现串台现象A会话的日志里混了B会话的数据。这时候可观测性反而会误导你让你以为是逻辑问题实际是隔离问题。我的做法是先做会话隔离再做可观测性。隔离做好了日志才是可信的。隔离没做好日志越详细越容易误导。6.3 一个推荐的落地顺序基于我自己的项目经验四道坎的落地顺序建议是会话隔离第一周这是基础不做这个后面都是空中楼阁。Token预算制第二周控制成本同时为工具调用优化创造条件。Schema分层工具描述工程第三周在干净的上下文和隔离的状态上做工具调用优化。全链路Trace回放第四周最后做可观测性因为前面的优化需要可观测性来验证效果。这个顺序不是绝对的但大方向是这样先做隔离和成本控制再做质量优化最后做可观测性。反过来做你会发现可观测性做完了看到的全是隔离和Token问题根本没法定位真正的逻辑问题。7. 一些踩坑之后才明白的实操细节7.1 Schema版本管理工具改了Schema要同步工具Schema是会变的。你今天加了一个参数明天改了一个枚举值。如果Schema版本管理没做好会出现LLM按旧Schema调用代码按新Schema执行的错位。我的做法是给每个工具Schema打版本号LLM调用时带上版本号执行层校验版本号TOOL_SCHEMAS { search_knowledge_base: { version: 1.2, schema: {...} } } def execute_tool(tool_name, params, schema_version): current TOOL_SCHEMAS[tool_name][version] if schema_version ! current: # 版本不匹配尝试兼容或报错 params migrate_params(tool_name, params, schema_version, current) return _execute(tool_name, params)这个机制在工具频繁迭代的项目里特别有用。没有它每次改Schema都要担心线上会不会出问题。7.2 Token计数不要用估算要用真实计数很多团队用字符数除以4来估算Token这在英文场景下勉强能用中文场景下误差很大。中文一个字符可能对应1到2个Token估算误差能到50%。我的做法是用模型对应的Tokenizer做真实计数。OpenAI的tiktoken、Anthropic的tokenizer、开源模型的tokenizer都有对应的库。虽然计数本身有开销但比估算准确得多。import tiktoken def count_tokens(text, modelgpt-4): encoding tiktoken.encoding_for_model(model) return len(encoding.encode(text))如果性能有要求可以缓存计数结果或者用采样估算定期校准的方式。7.3 工具超时和降级不要让一个慢工具拖垮整个Agent生产环境里工具可能超时。如果Agent傻等一个超时的工具整个响应就卡住了。我的做法是给每个工具设超时超时后走降级逻辑def call_tool_with_timeout(tool_name, params, timeout_ms5000): try: return execute_with_timeout(tool_name, params, timeout_ms) except TimeoutError: # 降级返回缓存结果或空结果 cached get_cached_result(tool_name, params) if cached: return cached return {error: 工具超时, fallback: True}降级逻辑要让LLM知道这个工具没返回结果这样LLM可以选择换一个工具或者告诉用户暂时查不到。7.4 并发压测上线前必须做最后一条也是最重要的一条上线前必须做并发压测。不要用单用户测试通过就上线。至少模拟10到50个并发会话跑够1000轮对话观察状态隔离、Token消耗、工具调用成功率、响应延迟。我见过太多团队跳过这一步上线后才发现问题。压测的成本远低于线上事故的成本。压测时重点观察不同会话的响应是否串台。Token消耗是否随并发线性增长应该是线性的如果超线性说明有共享状态。工具调用成功率是否随并发下降如果下降说明有资源竞争。P99延迟是否可接受。这些观察点能帮你提前发现大部分并发问题。8. 写在最后Agent生产落地是一场工程仗回到开头那个曲线Demo惊艳、上线拉胯。这个曲线的本质是Agent的Demo验证的是模型能力而生产验证的是工程能力。模型能力在过去两年提升很快但工程能力需要每个团队自己补。四道坎——工具调用、Token、并发、可观测性——每一道都不是模型问题是工程问题。工程问题的解法没有银弹就是一层一层做隔离、做预算、做校验、做追踪。我自己的体会是Agent项目从Demo到生产工作量的大头不在模型调优在工程基建。如果你正在做这件事把70%的精力放在工程上30%放在模型上。这个比例可能反直觉但实测下来就是这样。最后一个建议不要试图一次解决所有问题。按我上面说的顺序一周解决一道坎四周之后你会有一个能扛住生产流量的Agent系统。急着一次全上反而容易在某个点上卡住最后项目延期。
返回列表