从 Function Calling 到前端动画:一个奶爸程序员的深夜探索实录

·张思宇
2026-04-13 06:36
阅读 399

上个月刚换新东家,入职才两个月,就赶上了团队大重构。白天开会、改需求、被产品追着问“这个动效能下周上线吗?”,晚上回家还得陪俩娃洗澡讲故事。等他们睡了——差不多十点半——才是我的“第二班”时间。Vim 窗口一开,咖啡一杯,耳机一戴,这才真正进入 coder 模式。

最近我们项目要接入一个智能客服系统,后端用的是 Llama 系列模型(别问我为什么不是 GPT,公司合规要求+开源可控,懂的都懂)。但前端这边不能只是个“展示板”——用户点一下,弹个对话框,然后傻等几秒出结果?太 low 了。领导说:“得有点交互感,比如模型调用函数时,前端要有反馈动画。”
我心想:Function Calling 这玩意儿,不就是模型告诉前端“我要去查天气了”、“我要调支付接口了”,然后前端根据这些指令做 UI 反馈?

但问题是:怎么让前端优雅地处理这些动态指令?又怎么和 Llama 的输出无缝衔接?今天这篇,就聊聊我这两周在娃睡后、凌晨一点的 Vim 里折腾出来的方案。


起因:Llama 返回的不只是文本

我们用的是 Llama-3-8B-Instruct,通过 Ollama 部署在内网服务器。它的 Function Calling 能力其实挺强——只要你给它定义好工具(tools),它就能在生成回复的同时,附带一个 tool_calls 字段,告诉你它打算调哪些函数、参数是什么。

比如用户问:“帮我查下北京明天的天气。”
模型返回:

{
  "content": null,
  "tool_calls": [
    {
      "function": {
        "name": "get_weather",
        "arguments": "{\"city\": \"北京\", \"date\": \"2024-06-15\"}"
      }
    }
  ]
}

这时候,前端不能直接显示 null 啊!得知道“模型正在调用 get_weather”,于是我就想:能不能做个“思考中...”的微动效?甚至更进一步——根据函数名播放不同动画?比如查天气就飘朵云,支付就转个金币?

听起来简单,但真做起来,坑不少。


第一版:硬编码 + setTimeout,翻车现场

一开始我想偷懒,直接在 Vue 组件里写:

if (response.tool_calls) {
  const funcName = response.tool_calls[0].function.name;
  if (funcName === 'get_weather') {
    this.showCloudAnimation();
    setTimeout(() => this.fetchWeather(...), 800);
  } else if (funcName === 'make_payment') {
    this.showCoinSpin();
    setTimeout(() => this.callPayment(...), 1000);
  }
}

结果上线第二天就被测试小姐姐抓包:“你这个动画跟实际请求完全不同步!有时候动画播完了,数据还没回来,用户以为卡了。”

更惨的是,产品经理看了说:“以后要加新函数,你是不是每个都要改前端?这不符合‘配置即代码’的理念啊!”

我:……行吧,你说得对,是我图省事了。


第二版:用策略模式解耦动画与逻辑

痛定思痛,我决定搞个“函数-动画映射表”,配合一个通用执行器。

首先,定义一份前端可识别的函数清单(跟后端保持一致):

// functionRegistry.ts
export const FUNCTION_REGISTRY = {
  get_weather: {
    animation: 'cloudPulse',
    delay: 600,
    handler: async (args) => {
      const res = await api.getWeather(args.city, args.date);
      return `北京明天${res.temp}度,${res.desc}`;
    }
  },
  make_payment: {
    animation: 'coinRotate',
    delay: 900,
    handler: async (args) => {
      await api.processPayment(args.amount);
      return '支付成功!';
    }
  }
  // 后续新增只需在这里加,不用动主逻辑
};

然后在聊天组件里统一处理:

<script setup>
import { FUNCTION_REGISTRY } from '@/utils/functionRegistry';

const handleModelResponse = async (response) => {
  if (response.tool_calls?.length) {
    const call = response.tool_calls[0];
    const funcDef = FUNCTION_REGISTRY[call.function.name];
    
    if (!funcDef) {
      console.warn('Unknown function call:', call.function.name);
      return;
    }

    // 触发动画
    playAnimation(funcDef.animation);
    
    // 等待动画延迟后再执行真实请求
    await new Promise(resolve => setTimeout(resolve, funcDef.delay));
    
    try {
      const resultText = await funcDef.handler(JSON.parse(call.function.arguments));
      addMessage({ role: 'tool', content: resultText });
      
      // 再次发送给 Llama,让它基于工具结果生成最终回复
      const finalResp = await sendToLlama([...messages.value, { role: 'tool', content: resultText }]);
      addMessage(finalResp);
    } catch (err) {
      handleError(err);
    }
  } else {
    // 普通文本回复,直接显示
    addMessage(response);
  }
};
</script>

这下清爽多了!新增函数?只要在 FUNCTION_REGISTRY 里配一行就行。动画和业务逻辑彻底分离,测试也夸我“这次同步性好多了”。


关键细节:如何让 Llama 知道工具调用完成了?

很多人忽略了一点:Function Calling 是多轮对话的一部分。你不能只调一次 API 就完事。

正确流程应该是:

  1. 用户提问 → 前端发给 Llama
  2. Llama 返回 tool_calls → 前端执行对应函数
  3. 函数返回结果 → 前端把结果作为 role: "tool" 的消息发回 Llama
  4. Llama 再基于这个结果生成人类可读的最终回复

所以我们在前端必须维护完整的对话历史,包括 tool 类型的消息。否则 Llama 会懵:“我让你去查天气,你怎么没告诉我结果?”

我们的消息结构长这样:

const messages = [
  { role: 'user', content: '北京明天天气?' },
  { 
    role: 'assistant', 
    tool_calls: [{ function: { name: 'get_weather', arguments: '...' } }] 
  },
  { 
    role: 'tool', 
    content: '北京明天28度,多云' 
  },
  // ← 下一轮 Llama 会基于以上三句生成最终回复
]

这点特别容易踩坑——我第一次没传 tool 消息回去,Llama 直接回了个:“好的,我已经帮你查了。” 结果啥数据都没有,用户一脸问号。


性能与体验权衡:动画延迟真的必要吗?

有人会问:为什么还要 setTimeout 延迟?直接立刻发起请求不行吗?

答案是:为了让用户感知到“AI 在思考”

如果函数调用瞬间完成(比如本地缓存命中),用户看到的可能是:

用户:查天气
AI:北京明天28度

中间毫无过渡,用户会觉得“这 AI 怎么这么快?是不是假的?” 反而降低信任感。

所以我们故意加了 600~1000ms 的动画延迟,模拟“思考过程”。实测下来,用户满意度反而提升了——哪怕背后是毫秒级响应。

当然,对于真正耗时的操作(如支付),动画结束后立即发起请求,避免让用户等太久。


技术选型对比:为什么不用现成的 SDK?

其实市面上有 LangChain.js、LlamaIndex 前端库,它们内置了 Function Calling 处理。但我没用,原因有三:

方案 优点 缺点 是否采用
自研轻量逻辑 完全可控、无冗余、体积小 需自己处理边界情况 ✅ 是
LangChain.js 功能全、社区支持好 包太大(+300KB)、过度封装 ❌ 否
后端代理所有调用 前端无感、安全 失去交互灵活性、无法定制动画 ❌ 否

我们项目对 bundle size 敏感(移动端占比高),而且交互体验是核心卖点,所以自研反而是最优解。


写在最后:奶爸的时间管理哲学

说实话,这套方案前后改了三版,都是在娃睡后两小时内搞定的。有时候写着写着,听见隔壁房间哭一声,立马切出去哄娃;回来继续写,靠的就是 Vim 的 session 恢复 + tmux 分屏

技术探索不一定要大块时间。每天一小时,聚焦一个小问题,用真实项目驱动学习,反而比周末突击八小时更有效。

现在这个 Function Calling + 动画联动的方案已经上线两周,用户停留时长涨了 12%,产品还特意在周会上表扬了我(虽然紧接着就扔来三个新需求 😅)。

如果你也在带娃、加班、学新技术的夹缝中挣扎——别焦虑。每一个深夜的 commit,都是你对抗熵增的方式

下次再聊怎么用 Web Animations API 做更流畅的 Llama 交互动效。先去给老二换尿布了,告辞!

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝