ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

用Dify代码节点精简Prompt,客服问答成本降一半

用Dify代码节点精简Prompt,客服问答成本降一半 去年搭客服问答工作流的时候我把知识库召回段落、订单查询结果、历史对话全部用模板节点拼进系统提示词里。刚上线几天效果还行等到对话轮次变多、用户问法千奇百怪之后问题就暴露了每次请求的 token 消耗肉眼可见地涨回复开始抓不住重点有些用户明明在问发货时间模型反而在复述产品参数。后来我把这些“脏文本”统一交给 Dify 的代码节点做清洗、去重、截断、重组Prompt 从三千多字压到九百字上下成本下来了回复稳定性反而好了不少。这篇就用这个客服案例讲讲Dify 代码节点到底怎么精简 Prompt以及我在落地过程里踩过的一些坑。1. 先看问题Prompt越拼越长token和效果一起失控1.1 长Prompt是怎么长起来的只要在 Dify 里搭过复杂工作流的人基本都经历过这个演变过程。一开始系统提示词只有一段角色设定加两三句要求跑通之后开始接知识库把召回结果用模板节点拼进去接着为了上下文连贯把历史对话也拼进去后面接了订单查询、物流查询这类工具又要把工具返回的 JSON 原样塞进去。每一步单独看都合理但叠加到一起一次请求的系统提示词就能轻松超过三千字。这里有个特别容易忽略的点Dify 的模板节点本质上是“变量占位符加静态文本”它没有任何智能只会把变量原样替换进去。知识库召回多少段它就塞多少段历史对话有多少轮它就把多少轮全部平铺在提示词里。开发阶段一次两次测试感觉不到问题等真实用户进来对话轮次一多、知识库检索条目一多Prompt 长度就会指数级膨胀。我当时的客服工作流里Prompt 的主要来源有三个。第一个是知识库召回段一次召回 8 到 10 段每段平均 350 字光这里就有三千多字第二个是历史对话测试的时候只有两三轮上线后用户会有意无意聊十几轮全量拼进去至少两千字第三个是订单查询结果工具返回的 JSON 有几十个字段真正用到的其实只有订单状态、物流单号、最新节点这几项。三个来源加起来单次请求的消耗非常可观。1.2 长Prompt的真实代价长 Prompt 的代价不只是花钱。第一是 token 费用。按当时粗略统计三千多字的中文提示词经模型分词后大概要消耗 2000 到 3000 token。假设每天有几千次对话请求这里多出来的成本就是纯浪费。而且用户每多问一句历史对话就会更长下一次请求的费用又会上涨这是一个持续恶化的过程。第二是效果漂移。模型对超长上下文的注意力是有限度的当知识库段落、历史对话、工具结果挤在一起真正和当前问题相关的关键信息会被淹没。用户问“我的订单为什么还没到”模型如果被一大段产品介绍干扰回出来的内容就会偏题。很多团队遇到这种问题会去调整模型参数或者换更大的模型但根源往往只是 Prompt 里的噪声太多。第三是维护成本。模板节点里的变量引用一多改需求的时候根本不知道哪一段对应哪个上游节点。想加一句“请用简体中文回答”得在模板里翻半天还容易误删变量。相比之下代码节点把所有文本处理集中到一个入口规则写在 Python 函数里改动和排查都清楚得多。下面是精简前后同一份 Prompt 的结构对比可以直观感受到差距组成部分精简前精简后知识库召回段8 到 10 段每段约 350 字去重后最多保留 3 段每段 300 字以内历史对话全部轮次约 2000 字最近 3 轮每轮 150 字以内订单查询结果完整 JSON约 1200 字白名单 4 个关键字段约 120 字合计长度约 3500 字约 2500 token约 900 字约 600 token看到这个对比之后我才意识到Prompt 精简不是抠那几十个字而是把整个工作流里冗余的文本来源逐一收紧。2. 代码节点在Dify里的定位它不是补丁是文本加工中心2.1 模板节点解决不了的事情有人可能会问Dify 里不是有模板节点、条件分支节点吗为什么一定要用代码节点模板节点能做的只是拼接。你可以用{{变量}}把上游内容插进一段文字里但你没办法在模板里写“如果这段内容超过 300 字就截断”“如果这一段和前面重复就去掉”“如果 JSON 里没有某个字段就补一句占位”。模板节点的定位是给静态文案服务的不是给数据处理服务的。条件分支节点能做判断但判断之后你还是要回到模板节点里重新拼一份 Prompt。如果三个分支分别要拼三份不同的 Prompt模板里就会塞进大量重复的静态文本维护起来比不用分支还要痛苦。而且条件分支节点需要配置多条规则规则复杂之后排错非常费眼每次都像在做逻辑迷宫。真正适合干这件事的是代码节点。你可以把代码节点理解成一个透明的小型 Python 函数它接收上游节点的输出执行完逻辑后把结果交给下游。所有清洗、去重、截断、重组都可以在一个函数里完成规则是确定性的输入相同就永远输出相同这恰好是 Prompt 预处理最需要的性质。2.2 代码节点的输入、输出和运行边界用代码节点之前先把它的运行机制看清楚可以少走很多弯路。Dify 的代码节点支持 Python 和 Node.js 两种运行时。平时做文本处理Python 就够用标准库里的re、json、math基本覆盖所有场景。节点有一个主入口函数接收你配置的输入变量返回一个字典字典的键就是你在界面上配置的输出变量。配置输入变量的时候要明确指定类型。常用的有字符串、数字、数组、对象、布尔值。这里有个习惯值得养成所有变量统一用小写下划线命名比如knowledge_text、history_text避免后面写代码时大小写不一致导致排查半天。代码节点运行在受控环境里不能假设它能访问外部网络也不要依赖第三方库。有人想在代码节点里直接用某个 pypi 库做语义压缩实际执行就会发现环境里没有这个库。所以代码节点适合做规则明确的文本处理不适合做需要模型推理的语义任务。语义层面的压缩应该交给 LLM 节点代码节点只负责把模型能力之外的脏活累活接过来。2.3 什么情况适合上代码节点什么情况别硬上根据我这段时间的使用经验代码节点适合处理四类问题。第一清洗。比如从知识库召回的文本里去掉空行、去掉重复段、去掉 HTML 标签这些活用正则表达式写几行就完事模板节点完全做不了。第二截断。把超长文本按照长度阈值或者句末标点截断保留相对完整的信息。第三结构重组。把多个上游输出按业务需要重新排列成固定格式再喂给 LLM 节点。第四轻量路由。在代码里通过关键词或正则判断当前请求属于哪个意图输出一个状态字段给下游条件分支节点用能省掉不少分支规则的配置。反过来有些场景不应该硬上代码节点。比如你想让历史对话变得更精炼把它从两千字压成两百字摘要这是语义压缩应该用 LLM 节点。代码节点只能做“砍掉最近三轮以外的内容”这种机械截断做不到“帮用户总结他上一轮的核心诉求”。再比如需要生成自然语言文案这也应该交给模型。代码节点什么都管最后会变成一个又臭又长的正则堆反而难维护。3. 完整实操把一段三千字的Prompt压到九百字3.1 先想清楚输入输出再动手写代码我们以客服问答工作流为例目标是把系统提示词从三千字压到九百字。第一步不是写代码而是梳理清楚这个代码节点的输入和输出。输入变量我配置了四个knowledge_text字符串知识库召回的所有段落原始文本。history_text字符串历史对话记录格式可能是“用户xxx\n客服xxx”。tool_result字符串订单查询工具返回的 JSON 字符串。user_query字符串用户当前问到的问题。输出变量我配置了两个prompt_text字符串压缩并组装好的完整系统提示词。estimated_tokens数字提示词长度的粗略估算方便日志里观察每次压缩后的体量。这里有个经验输出变量不要只定义一个 prompt_text。多返回一个估算 token 数之类的调试字段后续在运行日志里能直接看到效果不用去数文本长度。等跑稳定了再决定要不要留着这个字段给后面的节点用。3.2 一套能直接抄的压缩代码下面这段代码就是我当时用的压缩逻辑注释写在关键位置方便对照。如果你用的 Dify 版本代码节点入口是main(inputs: dict)这种老式签名我会在第 4 节单独给兼容写法。import re import json def _clean(text, default): if not text: return default return str(text).strip() def _cut(text, limit): text re.sub(r\s, , text).strip() if len(text) limit: return text cut text[:limit] # 尽量在句号处断开避免从句子中间截掉 if 。 in cut: return cut.rsplit(。, 1)[0] 。 return cut def _dedup(paragraphs): seen, result set(), [] for p in paragraphs: key p[:40] if key not in seen: seen.add(key) result.append(p) return result def main( knowledge_text: str , history_text: str , tool_result: str {}, user_query: str , ): knowledge_text _clean(knowledge_text) history_text _clean(history_text) tool_result _clean(tool_result, {}) user_query _clean(user_query) # 1. 知识库分段、去重、限长 paragraphs [p.strip() for p in re.split(r\n, knowledge_text) if p.strip()] paragraphs _dedup(paragraphs) knowledge_buf [] current_len 0 for p in paragraphs: if current_len 1000: break p _cut(p, 300) knowledge_buf.append(p) current_len len(p) 1 knowledge_final \n.join(knowledge_buf)[:1000] # 2. 历史对话只保留最近 3 轮每轮限长 150 字 lines [l.strip() for l in history_text.splitlines() if l.strip()] if len(lines) 6: lines lines[-6:] history_final \n.join(lines) if len(history_final) 900: history_final history_final[-900:] # 3. 订单查询结果只提取关键字段 tool_fields {} try: parsed json.loads(tool_result) except Exception: parsed None if isinstance(parsed, dict): for key in [order_status, tracking_no, latest_event, refund_status]: if key in parsed: tool_fields[key] parsed[key] elif isinstance(parsed, list) and parsed: record parsed[0] if isinstance(record, dict): for key in [order_status, tracking_no, latest_event, refund_status]: if key in record: tool_fields[key] record[key] tool_final json.dumps(tool_fields, ensure_asciiFalse) # 4. 组装精简后的系统提示词 prompt f你是某电商平台的客服助手。请严格依据下方资料回答用户问题资料不足时如实说明。 【知识库】 {knowledge_final} 【历史对话】 {history_final} 【订单信息】 {tool_final} 【用户问题】 {user_query} return { prompt_text: prompt.strip(), estimated_tokens: max(1, len(prompt.strip()) // 3), }这段代码跑通之后我的 Prompt 长度就从三千字掉到了九百字左右。需要注意estimated_tokens用的是len(text) // 3这种粗略估算中文按每三个字符约等于一个 token 来算实际值会随模型分词器有波动但用于观察压缩趋势已经足够了。3.3 压缩策略里的几个取舍很多人第一次写这种代码会陷入一个误区想把所有信息都保住于是截断阈值不敢设太低。实际上概率模型不是靠“记住所有内容”来回话的它靠的是抓住与当前问题最相关的部分。思路要转换一下Prompt 里应该只留“和当前用户问题有关”的信息而不是把整个上下文搬进去。知识库部分我选择去重加限长。去重的指纹取每段前 40 个字符足够区分两段不同的内容。限长分两层单段限长 300 字避免某一段特别长的段落独占上下文整体限长 1000 字给历史对话和工具结果留出空间。这里用到的是典型的分层预算思路先定总预算再分给每个模块而不是让每一段都在极限边缘。历史对话部分只保留最近 3 轮。这个取舍一开始我也犹豫担心丢掉前面几轮的背景信息。实际测试下来客服场景里用户当前问题与最近几轮的语义关联最强前面那些已经被答过的内容对当前问题帮助有限。如果后面发现某些用户确实需要跨很多轮才能说清需求那就是另一套方案再加一个 LLM 摘要节点把老对话压成一段摘要再接上最近几轮。工具返回结果部分用白名单字段提取只保留订单状态、物流单号、最新节点、退款状态这几个字段。JSON 里其他字段比如库存编码、内部备注模型根本不需要知道。这一步节省的 token 量非常惊人因为 JSON 字段名的重复率高删掉无关字段等于直接砍掉一大截文本。3.4 把精简后的Prompt接回LLM节点代码节点写完下一步是把prompt_text接进 LLM 节点的系统提示词里。在 Dify 编辑器里LLM 节点的系统提示词输入框右侧可以选择变量找到上一级代码节点的prompt_text输出选中即可。注意别把用户当前问题再重复塞进系统提示词user_query应该直接走用户输入否则用户问题出现两次模型反而可能混乱。跑通之后先在一次真实请求里看看运行日志。日志里能找到代码节点返回的prompt_text内容确认没有出现“本来是 JSON 却变成了字符串”“历史对话全堆在一起”这类问题。我第一次接入时工具结果字段没有order_status代码直接抛了异常节点标红工作流中断。这就是为什么代码里必须对tool_result解析做异常容错线上真实返回的数据永远比你测试时拿到的样例更脏。这里顺带说一句不是所有工作流都需要把系统提示词做成本文这种固定格式。如果你的场景里知识库召回数量本来就少、历史对话也不长那模板节点拼接完全够用没必要为精简而精简。代码节点的价值在复杂场景下才会充分体现。4. 落地时最容易翻车的四个细节4.1 空值问题None不是空字符串别直接拼接代码节点最常见的翻车点就是上游节点没有传值。比如用户第一轮提问时历史对话为空history_text可能是None也可能是空字符串。如果你在代码里直接写history_text.splitlines()空字符串没问题None会直接报AttributeError。后来我养成了一个习惯代码节点入口统一走一遍兜底处理任何输入变量都先转成字符串再处理。这也是上面代码里_clean函数存在的意义。别嫌这一步麻烦代码节点一旦在运行中抛异常整条工作流直接中断下游 LLM 节点连执行的机会都没有这个代价远比多写两行兜底大得多。我的兜底规则是给所有输入变量提供默认值kwargs风格优先函数内部再用_clean()统一收敛。这样即使上游节点没输出也能保证代码不炸最多返回一段内容较空的 prompt让模型根据现有信息回答或者走人工转接分支。4.2 JSON解析工具返回结果没你想的那么规整工具节点返回的字段界面上看是结构化 JSON但只要转成字符串再传进代码节点就可能出现各种奇怪情况。最常见的两个第一个是 JSON 字符串首尾带多余空白字符第二个是字符串里夹杂了模型或者工具自己添加的解释文字导致json.loads直接失败。我遇到最典型的一次是返回的 JSON 里带了一个多余的尾逗号标准 JSON 解析直接报错。排查了半天才发现是上游处理环节拼接字符串时留下的。解决方案是在解析前做一层预处理先把首尾空白去掉再尝试把一些不标准的尾逗号替换掉。如果这样都解析不出来就退回一个空字典不要让异常冒泡出去。另外输出 JSON 时一定要用ensure_asciiFalse。否则 Python 会把中文字段变成\uXXXX形式的转义序列拼进 Prompt 里模型也能读但视觉审计日志时几乎没法看排查问题的效率会下降很多。4.3 输出变量名对不上节点就给你颜色看代码节点的返回字典键必须和你配置的输出变量完全一致。我在初期犯过一次低级错误配置的输出变量叫prompt_text代码里返回的字典键写成了prompt结果节点运行失败错误提示还不算特别直观我花了十几分钟才找到原因。正确做法是配置完输出变量后回到代码区核对一遍函数末尾的return字典。如果你配置了多个输出变量一个都不能少。这里推荐一个调试小技巧临时多设置一个输出变量debug_info类型选字符串把中间处理完的各段文本拼进去返回。只要运行一次日志里就能看到每一段到底被处理成了什么样比我对着代码猜半天高效得多。4.4 新老版本入口函数差异和调试习惯Dify 的代码节点在不同版本里的入口写法略有变化。老版本模板生成的代码是def main(inputs: dict) - dict: knowledge_text inputs.get(knowledge_text, ) ... return {prompt_text: prompt.strip()}新版本里更常见的是具名参数风格也就是第 3 节代码里那种直接写def main(knowledge_text: str, ...)的形式。如果你照着网上某篇文章复制代码但节点一直报参数错误先看一眼自己版本的点子模板用的是哪种签名再做对应调整。我自己现在遇到代码节点问题时调试顺序是固定的先看运行日志有没有抛异常再确认输入变量是否真的传到了节点最后才去看代码逻辑。很多人一上来就盯着代码看实际上有一半的问题出在节点配置或者上游输出上代码是背锅的。把调试顺序固定下来之后排错速度会快很多。5. 再往前走一步把代码节点用在更多环节5.1 历史会话摘要化而不是单纯截断第 3 节的代码里历史对话用的策略是只保留最近 3 轮。这个策略在大部分客服场景够用但如果用户问的问题牵扯到很早之前的对话单纯截断就会丢失信息。更好的方案是组合使用把较早的历史对话交给一个 LLM 摘要节点让它用 200 字左右概括用户的诉求和已确认的信息然后把这个摘要作为历史对话字段传进代码节点。代码节点负责拼接时只把“摘要 最近 3 轮”拼进去。这样既保住了长期背景又控制了长度。两个节点各司其职摘要交给模型拼接和截断交给代码互不干扰。5.2 按意图过滤工具结果字段工具结果的字段白名单可以做得更聪明一些。比如用户询问物流代码节点可以只返回tracking_no和latest_event用户询问退款就只返回refund_status和相关说明。在代码节点里用几个关键词正则分组判断意图然后动态决定提取哪一组字段比在界面上配一堆条件分支要清爽得多。if 退款 in user_query or 退货 in user_query: wanted_keys [refund_status, latest_event] elif 物流 in user_query or 到哪 in user_query: wanted_keys [tracking_no, latest_event] else: wanted_keys [order_status, tracking_no, latest_event]这种做法把路由逻辑收敛在代码内部输出结构更一致下游 LLM 节点收到的始终是“订单信息”模块只是内容按意图变化。条件分支节点仍然有用但更适合做“是否需要转人工”这种粗粒度的流程控制而不是细粒度字段筛选。5.3 把清洗逻辑沉淀成可复用模块代码节点的代码是可以复用的。如果你在多个工作流里都要做知识库文本清洗可以把这段逻辑保存成一份标准代码每次新建代码节点时直接粘贴然后只修改输入输出变量名。我习惯把这套代码维护在一个本地文档里字段名变更时统一改再重新粘到各个工作流。更进一步可以在 Dify 里把“文本清洗 Prompt 组装”抽成一个独立的工作流然后在其他工作流中通过子流程调用。这样清洗规则只需要维护一处任何调整都不会牵一发动全身。唯一要注意的是子流程的输入输出参数要定义清楚否则上游传参时很容易对不上变量名。我自己现在搭工作流的标准动作是先列一份“这份 Prompt 里哪些内容是可裁剪的”清单再决定哪些交给代码节点、哪些交给摘要模型。把裁剪规则定清楚、字段名统一之后很多时候效果提升比换更强的模型来得还明显。代码节点看起来只是一个小工具但它让整个 Prompt 供应链变得可控了。最后再分享一个小技巧所有代码节点里返回的字段命名尽量稳定。比如prompt_text、estimated_tokens这种名字我用到现在没变过。字段名一旦到处都是后续换人维护光对齐变量就要花半天。稳定命名带来的长期收益往往比写好几行压缩逻辑还要值。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表