首页 / 文章

配备SmolLM3思考模式、工具与约束机制的本地支持代理

作为客服在笔记本电脑上运行30亿参数的模型:何时使用think或no_think,工具调用机制是怎样的,以及哪些机制能阻止占位符工具的调用。

发布者:REACTAPP.TOP阅读约 9 分钟1979 词

来源: **A Local Support Agent with SmolLM3: Think Mode, Tools, and When Not to Reason**

有没有可能仅靠30亿参数的模型来处理支持工单,而无需700亿参数的模型作为后盾?对于那些较为简单的90%的工单来说是可以的,但前提是不要过分依赖模型自身的工具调用功能。在这里,SmolLM3以小型客服代理的形式在本地运行,能够显示出它的行为表现以及周边代码需要比模型本身更加严格的地方。

客户支持工作并不适合使用通用的聊天机器人。大多数工单只需要进行信息查询、分类并给出简短的规则答复,而少数工单则涉及重复收费、订单丢失或客户不满等问题。若要让同一个模型同时很好地处理这两类工单,就必须以两种不同的方式来运行它。

SmolLM3-3B之所以适合这一需求,有三个参数数量难以体现的原因:

  • 它内置了两种推理模式。加上/no_think指令即可让它立即回答,而加上/think指令则会让它先生成隐藏的推理轨迹。
  • 内置工具调用功能:以JSON模式描述的工具会被纳入聊天模板,当模型需要执行相应操作时,会通过<tool_call>标签进行响应。
  • 其体积小到足以在笔记本电脑上运行。所有功能都在配备24GB统一内存的MacBook上实现:使用MPS架构通过Transformers运行全精度模型,同时为智能体循环构建了8位精度的MLX版本。
  • 无需进行任何训练,模型权重也永远不会被修改。整个系统的核心在于将思考过程与执行动作分开,并让周边代码的约束比模型本身更为严格。

    环境配置

    有两个脚本使用不同的运行时,但都属于同一模型系列。在思考模式对比中,采样过程遵循模型配置参数,即temperature=0.6和top_p=0.95。当设置temperature=0.3时,智能体的响应会更保守,从而使工具相关的JSON数据保持不变。

    在这台机器上,8位代理的往返处理时间约为四秒。基于Transformers的完整30亿参数模型每秒能生成大约10个标记,这个速度足以用于后续的日志阅读,但对于正在查看聊天窗口的客户来说则太慢了。Transformers在研究模板时非常有用。当对话对方是真人时,应切换到MLX或llama.cpp。

    决策需谨慎

    SmolLM3默认处于思考状态。有两种等效的方法可以关闭这一功能,其中一种是通过系统消息来实现:

    messages = [
        {"role": "system", "content": "/no_think"},
        {"role": "user", "content": prompt},
    ]
    

    另一个是在渲染模板时使用的标志:

    tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
        enable_thinking=False,  # the /no_think flag in the system prompt wins if both are set
    )
    

    该模板的作用不仅仅是切换布尔值。它还会将推理模式写入系统头部,当选择 /no_think 时,还会提前插入一个空的思考块,从而使生成过程能直接继续输出回复:

    <|im_start|>assistant
    <think>
    
    </think>
    

    这就是为什么在无思考模式下直接调用 generate() 仍然会在文本中返回 <think></think>。对于面向客户的回复,需要将其去除:

    final = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
    

    两种模式下都出现了相同的支持提示:有客户因同一笔订单被收取了两次费用,那么支持团队应该采取哪三个具体措施?

    两种处理方式

    两种模式下的处理效率大致相同,但所需的工作量却不同。思考模式将939个标记中的大部分用于思考模块内。客户虽然看不到这些记录,但仍需等待处理完成。

    两种操作指南

    /no_think模式直接采用标准检查清单:确认订单、联系商家、退款。这可以作为初步方案,但对于商家的内部支持团队来说稍显不妥,因为“联系商家”是银行给出的建议,并非内部客服会告知客户的做法。

    /think 花了大约一页的篇幅来讨论是退款还是积分补偿、问题升级方式以及是否需要核查物流情况,最终得出了一个更具体的方案:

    1. 致歉并获取订单号、日期和支付方式。
    2. 找到重复的交易记录并予以取消。
    3. 向客户解释问题所在,并确认款项会以退款或店铺积分的形式返还。

    由此便产生了不同的处理方式:

    • 对于大量重复性工作,使用/no_think:意图分类、“我的订单在哪里”这类常见问题回复以及工具调用。
    • 在出现错误处理成本较高的情况时,使用/think:如退款、拒付、安全问题,以及任何需要在回复前按照类似人工流程逐一检查的情况。

    Hugging Face 自身的数据也显示了工具使用情况的类似趋势。在 BFCL 基准测试中,SmolLM3 在不进行扩展思考时的得分是 92.3,而进行扩展思考时则为 88.8。思考并非免费服务,而且对于这类任务而言,它甚至并没有带来更好的效果。下面的代理程序会强制设置 enable_thinking=False,因为查询订单应该只是函数调用,而非冗长的文章。

    一个工具与两轮对话循环

    SmolLM3 需要以 JSON-schema 对象的形式通过 xml_tools 传递工具信息。模板会将这些对象复制到 tools 块中,并指示模型按照该结构进行回复:

    <tool_call>
    {"name": <function-name>, "arguments": <args-json-object>}
    </tool_call>
    

    该支持工具的设计范围是有意限定的:

    TOOLS = [
        {
            "name": "lookup_order_status",
            "description": (
                "Look up the current status, estimated delivery date, and carrier "
                "for a specific customer order. Call this when the customer mentions "
                "an order number or asks where their order is."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "order_id": {
                        "type": "string",
                        "description": "The order ID, usually in the format ORD-XXXXXX.",
                    }
                },
                "required": ["order_id"],
            },
        }
    ]
    

    其背后是一个虚拟数据库。在实际应用中,这会对应一条 SQL 查询或对订单服务的调用。

    ORDERS = {
        "ORD-4821": {"status": "shipped", "eta": "June 18, 2026", "carrier": "DHL"},
        "ORD-3307": {"status": "processing", "eta": "June 20, 2026", "carrier": None},
        "ORD-1190": {"status": "delivered", "eta": None, "carrier": "FedEx"},
    }
    

    整个流程就是一个循环。模型接收客户消息,生成初步回复;如果是工具调用,则代码会执行相应函数,将结果反馈给模型后再请求第二次回复:

    def respond_with_tools(user_message: str) -> str:
        messages = [{"role": "user", "content": user_message}]
        turn1 = generate_reply(messages)
        name, args = parse_tool_call(turn1)
        if name == "lookup_order_status":
            result = lookup_order_status(**args)
            messages += [
                {"role": "assistant", "content": turn1},
                {"role": "tool", "content": json.dumps(result), "name": name},
            ]
            return generate_reply(messages)
        return turn1
    

    顺利流程

    一周后,有客户询问订单ORD-4821的进度。模型没有回答,而是调用了相应工具:

    <tool_call>
    {"name": "lookup_order_status", "arguments": {"order_id": "ORD-4821"}}
    </tool_call>
    

    该函数返回的发货信息为2026年6月18日通过DHL寄出。第二次查询耗时4.2秒,得到的回复是订单已发货,预计将通过DHL在6月18日送达。

    对于ORD-3307,当前状态为处理中,尚未确定承运商,预计送达时间为6月20日。模型直接说明没有可用的承运商而非编造一个,这正是人们对小型模型的期望:读取工具的JSON数据而不加以修饰。

    对于ORD-9999,工具调用是正确的,函数返回的值为not_found。系统给出了道歉并要求客户核对订单号码。函数直接返回结构化的“未找到”信息就已足够,无需额外提示。

    简单循环的局限性

    第三个查询属于阴性对照:客户只是想更改邮箱地址。由于不存在相应的邮件处理工具,优秀的客服会直接告知客户并结束对话。而那个简单的循环却做出了不同的反应:

    1. 它一开始的回应是正确的,表示无法更改个人信息,建议客户联系客服。
    2. 但随后它继续运行,发送了一个包含占位符的工具调用请求:
    <tool_call>
    {"name": "lookup_order_status", "arguments": {"order_id": "your_order_id"}}
    </tool_call>
    
    1. 解析器执行了所有用 <tool_call> 包裹的内容。它在虚假数据库中查找 your_order_id,得到 not_found 的结果后将其传回模型。
    2. 在第二次尝试时,模型向客户展示了该工具的架构,并要求他们用真实的订单号替换 your_order_id。

    有四件事同时出错且彼此叠加。其中模型的问题最不值得关注。小型模型通常会采取谨慎策略,这是正常现象。如果你的处理流程只是用正则表达式匹配 <tool_call>,然后使用出现的任意参数调用函数,那么在正式环境中最终会执行 lookup_order_status("your_order_id"),而在演示环境中则不会发生这种情况。

    无需微调的防护措施

    两条简单的规则就能在不改变权重的情况下解决大部分问题。

    首先,只有当整个对话内容仅为某次工具调用时才进行该调用。段落之后紧跟的调用被视为普通文本,应在客户看到之前将其从XML中移除。

    其次,在参数传入数据库之前需对其进行验证。此处的订单编号格式为ORD-加上数字,因此your_order_id这类格式的编号永远无法通过验证。

    ORDER_RE = re.compile(r"^ORD-\d{4,}quot;)
    PURE_TOOL_RE = re.compile(r"^\s*<tool_call>(.*?)</tool_call>\s*quot;, re.DOTALL)
    
    def parse_executable_tool_call(output: str):
        cleaned = re.sub(r"<think>.*?</think>", "", output, flags=re.DOTALL).strip()
        match = PURE_TOOL_RE.match(cleaned)
        if not match:
            return None
        payload = json.loads(match.group(1).strip())
        order_id = str(payload.get("arguments", {}).get("order_id", "")).strip()
        if not ORDER_RE.match(order_id):
            return None
        return payload
    

    也要进一步明确工具的使用说明,规定只有在客户已提供具体订单编号时才能调用该工具。做出这一修改后,受保护流程中的邮件查询完全不再调用任何工具,耗时3.5秒,而原有有缺陷的两步流程则需15.4秒。回复中仅表达了歉意,称无法处理账户变更问题,并建议客户联系客服。虽然这种回应仍很笼统,但既然没有update_email工具,本就不应存在自动处理的路径。至少它不再假装在查询订单了。

    正常流程下的查询功能依然有效,但ORD-9999仍被报告为丢失状态。该模型并未变得更智能。这更像是安全带而非真正的解决方案。

    这无法证明什么

    这只是个演示版本,真正的支持助手需要远不止一个功能以及三笔订单的词典数据。有几项说法其实站不住脚:

    • 这并非多工具规划。其中一种工具仅用于查询,而大约两种工具的情况下,3B模型才会开始自行生成订单ID,以符合它们并未完全理解的架构要求。
    • 数据库是虚假的,因此身份验证、个人数据以及“允许该用户查看此订单”等功能从来都不是模型需要处理的问题。
    • 整个过程中都启用了抽样机制,因此重新执行邮件查询不会得到相同的输出内容。反复运行中出现的始终是失败现象,即工具调用返回的仅为占位符。具体表述会有所不同,不可依赖其一致性。
    • 没有任何内容与云端模型进行对比,也没有基于真实的订单导出数据进行测试,因此这些数据仅反映某台机器的性能,而非基准值。

    这样的说法比“小型模型能够提供支持”更为有限:一个30亿参数的模型可以调用一个描述完善的工具,汇总其结果后便不再干预,前提是必须将工具调用视为需要权限验证的操作,而非因为模型生成了结果就值得信任。

    运行测试

    在已激活虚拟环境的项目目录中,按顺序运行三个脚本:进行有思考与无思考模式的对比测试、普通智能体测试以及受保护智能体测试。

    python llm-learning/smollm3-local-support-agent/code/think_vs_nothink.py
    python llm-learning/smollm3-local-support-agent/code/support_agent.py
    python llm-learning/smollm3-local-support-agent/code/support_agent_guarded.py
    

    这些脚本会生成文字记录,供你事后阅读以对比不同运行结果。