TriggerHappy
I taught a small model to use tools better. It also got trigger-happy. Then I looked inside to find out why, and tried two fixes that both fooled me first.
The short version (っ◔◡◔)っ
- Fine-tuning made the model better at requests that need several tool calls, in all four training runs.
- It also made it call tools when none fit. The overall score hid this, because the gain and the loss cancelled out.
- Looking inside the model, it can tell when no tool fits. It’s less sure about calling, just not less sure enough.
- I tried two fixes. Both looked like they worked, and both failed a fairer test.
What I was trying to find out (・・ ) ?
A “tool-calling” model is one that, instead of just answering in text, can decide to call a function like get_weather(city="Paris"). Coding agents live on this skill: read a file, run the tests, search the repo. Small models are bad at it, so I asked three questions:
- Does a cheap fine-tune help a 1.7B model on a standard benchmark, and what does it cost?
- When the fine-tuned model calls a tool it shouldn’t, has it lost the ability to tell, or does it know and call anyway?
- If it knows, can I use that to fix it?
The process, start to finish (ง •̀_•́)ง
Here is the whole project in order. Each step below gets its own section further down.
- Build the training data: 8,000 tool-calling examples, plus a share of “no tool fits, answer in text” examples. Remove anything that overlaps with the test set. (xLAM-60k · xlam-irrelevance-7.5k)
- Train with LoRA: Freeze the model, train a small add-on. Four runs: two seeds × two data mixes. (6 GB laptop GPU · ~4 h per run)
- Score on BFCL, per category: Compare each run with the untouched model on the same 1,240 prompts, with a paired statistical test.
- Read the failures: Look at actual outputs to see what “worse” means in practice.
- Probe the hidden states: Check whether the model can still tell “a tool fits” from “no tool fits” inside, even when it calls anyway.
- Try two fixes: Retrain with more “don’t call” data. Then steer the model along the direction the probe found.
- Re-test each fix more fairly: A second seed for the retrain, and a matched comparison for steering. Both fixes failed here (lol)
Step 1–2: data and training ( •̀ ω •́ )✧
The data. I took 8,000 examples from Salesforce’s xLAM dataset.
Each one is a user request, a list of available tools, and the correct call. I mixed in “no tool fits” examples from a second dataset, where the correct reply is a fixed sentence instead of a call: 10% of the mix in two runs, 25% in the other two. Before training, I removed 17 examples that were near-copies of benchmark questions (checked each by eye) so the model couldn’t memorize the test, and 473 that were longer than 1,024 tokens so they’d fit in memory.
LoRA, in one equation. A model is mostly big matrices of weights. Fine-tuning normally changes all of them, which needs far more memory than a 6 GB GPU has. LoRA keeps every original matrix W frozen and learns a small correction made of two thin matrices, B and A:
Training. One pass over the data, 16 examples per step, learning rate 2×10⁻⁴. The loss only counts the model’s reply, not the prompt, so it learns to answer, not to repeat requests.
Peak memory was 4.58 GB, about 4 hours per run. I trained four runs: two random seeds for each data mix. Seeds matter more than people expect, as Fix A will show.
Step 3: how I scored it
I used BFCL, the Berkeley Function Calling Leaderboard. It splits 1,240 single-turn requests into five kinds:
- simple: one tool, one call.
- multiple: several tools, one fits.
- parallel: the same tool, several calls.
- parallel-multiple: several tools, several calls.
- irrelevance: no tool fits, so the right answer is to not call anything.
BFCL’s own checker needs a server library my laptop couldn’t run (funny life) so I wrote a scorer that follows its matching rules: names and strings compared ignoring case, spaces and punctuation, optional arguments allowed to be missing, calls accepted in any order. A stricter type check changed 0 verdicts.
Get Vin’s stories in your inbox
Join Medium for free to get updates from this writer.
Why “paired” matters. Both models answer the exact same prompts. For each prompt there are four outcomes: both right, both wrong, fixed (wrong before, right after), or broke (right before, wrong after). Only fixed and broke tell you anything about the difference.
The exact McNemar test asks: if fine-tuning did nothing, each disagreement would be a coin flip between fixed and broke, so how unlikely is the split I saw? For parallel-multiple in one run, 23 fixed against 8 broke has about a 1% chance under a fair coin (p = 0.011).
Result 1: better at calling, worse at holding back (⊙_⊙;)
The overall score went from 85.8% to about 86–87%. That looks like “the fine-tune did basically nothing.” Split it by category and you get a very different picture.
Reading the actual outputs explained both sides.
The gain is mostly the model making the right number of calls: the untouched model often stopped at 2 when 3 were needed. The loss is real, unneeded calls, usually with made-up arguments. I read 15 at random and all 15 looked like this:
The stats tool can’t answer “who won,” and the model invents a player and a season to call it anyway. In an agent, that is a wasted step at best. If the tool sends a message or writes to a database, it’s worse.
Result 2: it knows, and it calls anyway (¬‿¬)
So did fine-tuning break the model’s ability to tell that no tool fits? Or does it still know, and call anyway?
The output can’t tell you, because in both cases you see the same wrong call. So I looked at the model’s internals with a linear probe.
While a model reads a prompt, each layer holds a hidden state: a list of numbers summarizing what it has read so far, like a point in a very high-dimensional space. If the model separates “a tool fits” from “no tool fits,” the two kinds of prompts should land in slightly different regions. Here’s the pipeline:
Step by step: I built 1,000 matched pairs with the same request and the same number of tools. In one version the needed tool is listed; in the other it’s swapped for an unrelated one. Because everything else is identical, any difference inside the model has to come from whether a tool fits. I saved the hidden state h at the last prompt token, at every layer, and on 70% of pairs averaged each group and subtracted:
To measure how well this separates the groups I used AUROC: pick one random “no fit” prompt and one random “fit” prompt, and check how often the no-fit one scores higher. 0.5 is guessing; 1.0 is perfect.
A trap I almost fell into Σ(°△°|||)
In the late layers, even random directions and probes trained on shuffled labels scored about 0.99. At that token, the model’s upcoming choice (“I’m about to call” or “I’m not”) dominates everything. A naive probe would have “found” the concept perfectly while really just reading the decision.
So I held the decision fixed. I read each model’s choice off its final hidden state (it matched the real output on 98–100% of prompts) and compared only prompts where the model was about to call.
So the fine-tuned model hasn’t lost the information. It’s measurably less sure about calling when no tool fits, and you can see it right in its output probabilities. It just isn’t less sure enough, so the call still wins. The problem is the decision, not the knowledge.
One more detail points at why. On “no fit” prompts that look like the training data, the fine-tuned models actually call less than the untouched one (60 and 70 per 1,000, against 88). On BFCL they call more. BFCL’s “no fit” prompts usually list a related tool that still can’t answer the question, like the stats tool above, and the training data had nothing like that.
Result 3: two fixes, and how each one fooled me… (╥﹏╥)
Fix A: more “don’t call” examples (・_・;)
The idea: if the model saw more examples of holding back, it should hold back more. I raised the share of “no tool fits, answer in text” examples in training from 10% to 25%, kept everything else the same, and trained it with both seeds.
With seed 0 alone, this looked like a fix: 16 prompts fixed, none broken. Seed 1 didn’t repeat it (life just sucks). The 2.3-point difference in means is smaller than the gap between two seeds of the same setting, so I can’t claim any effect. My best guess (untested) is that the extra examples look like xLAM data, which the model already handles, and not like BFCL’s “related but wrong tool” prompts.
Fix B: steering (>﹏<)
Steering uses the probe’s arrow as a control instead of a measurement. If d points from “fit” toward “no fit,” then adding it to the hidden state should make the model act as if no tool fits.
I picked α = 2 on 440 tuning prompts, then tested on new ones:
The first test looked great: on real users’ messages where no tool fits, 56 fixed and 0 broken, while synthetic prompts where a tool fits barely moved. But those two groups differ in more than “does a tool fit.” One is messy real-user text, the other is clean synthetic text. A push that just refuses anything that sounds like a real user would produce the same table.
So I ran the matched test: real users’ messages where a tool does fit. Accuracy dropped from 391 to 336 of 558. On real-user messages, steering adds about 19 points of “no call” whether or not a tool fits. It was never selective. It only looked that way because my first comparison was mismatched.
A direction that separates two groups tells you the information is there. It doesn’t tell you what pushing on it will do.
Limits ( ̄▽ ̄*)ゞ
One model. Two seeds per setting. My own scorer, which follows BFCL’s matching rules but isn’t the official checker (a stricter type check changed 0 verdicts). Steering on one seed and one layer. Small probe test sets. Single-turn only, prompts capped at 1,024 tokens. Not checked for forgetting of other skills. I didn’t do a careful search of related work, so treat “knows but calls” as my own readinngs.
Adapter: vinnvinn/trigger-happy-qwen3–1.7b-lora · Code, full report and every model output: github.com/Vidushi2709/trigger-happy
Setup: LoRA rank 16, alpha 32, all linear layers, lr 2e-4, 1 epoch, 8,000 xLAM examples plus 10% or 25% xlam-irrelevance. 17 near-copies of BFCL questions removed before training. RTX 3050 laptop, 6 GB (aka my baby)
References: Hu et al. 2021 (LoRA) · Liu et al. 2024 (APIGen/xLAM) · Lin et al. 2024 (Hammer) · Patil et al. (Gorilla/BFCL) · Alain & Bengio 2016 (linear probes) · Turner et al. 2023 (Activation Addition) · Rimsky et al. 2024 (Contrastive Activation Addition).








