DEV Community

Cover image for Building a Deterministic Financial Agent with Dual-Persona LoRA
Thien Ban BUI
Thien Ban BUI

Posted on

Building a Deterministic Financial Agent with Dual-Persona LoRA

In casual chat, a 0.01% rounding discrepancy is trivial. In a multi-billion dollar M&A due diligence, a regulatory filing under IFRS/US GAAP, or a leveraged buyout (LBO) debt covenant check, that single basis point is an audit failure.

Every engineering team that has tried deploying Large Language Models (LLMs) in quantitative industries (finance, accounting, legal risk, insurance) has run head-first into the same wall: LLMs are stochastic next-token predictors, not math engines.

In this 3-part series, I share our research, architectural breakthroughs, and empirical benchmarks building a sovereign, zero-hallucination financial intelligence system.

In this first article, we will break down:

  1. Why prompting techniques (Chain-of-Thought, few-shot) can never guarantee deterministic accounting.
  2. The "Dual-Persona" architecture: achieving strict tool adherence without losing conversational eloquence.
  3. Zero-overhead LoRA hot-swapping: running both specialist tool routing and C-level executive synthesis on a single model in GPU VRAM with 0 ms switching latency.
  4. The local sovereign imperative: why financial audits require a zero-data-egress, air-gapped pipeline.

1. The Trap: LLMs Trying to Calculate

Large Language Models do not possess an internal calculator. When an LLM outputs:
"245.0 / 1450.5"
it is not computing a division. It is calculating the statistical probability of the character sequence "16.90" appearing after "245.0 / 1450.5" based on patterns ingested during pretraining.

When numbers are common or memorized, it appears correct. When numbers are arbitrary real-world balance sheet figures (e.g., "$1,450.5 M$" revenue vs "$245.0 M$" EBIT), probabilistic drift creeps in:

Prompt: "Compute the operating margin for an EBIT of 245.0M$ and Revenue of 1450.5M$"

Standard 8B Zero-Shot:
- "The operating margin is around 16.9% or 17%..."  ❌ (Vague rounding)
- "Calculation: (245.0 / 1450.5) * 100 = 16.90%"      ❌ (Off by 1 basis point)
- "The ratio is 16.89%"                              ⚠️ (Lucky guess, non-reproducible)
Enter fullscreen mode Exit fullscreen mode

No amount of prompt engineering, "Think step by step", or "Act as a Senior Investment Banker" can alter the mathematical physics of auto-regressive decoding. Any nonzero temperature introduces stochastic variation into non-negotiable arithmetic.

The Law of Deterministic Delegation

Rule #1: Never let a language model calculate numbers mentally.

Rule #2: Force the model to act as a pure semantic compiler: extract variables, identify accounting norms, and delegate execution to a deterministic hardware kernel (Python IEEE-754).


2. The Architectural Dilemma: Multi-Agent Bloat vs. Specialist Amnesia

To solve this, traditional agentic architectures (like default LangChain or CrewAI setups) spin up multiple independent agents:

[User Question]
      │
      ▼
┌──────────────┐   HTTP / RPC   ┌────────────────┐   HTTP   ┌───────────────┐
│ Router Agent │ ─────────────► │ Worker Agent   │ ───────► │ Writer Agent  │
│ (Container 1)│                │ (Container 2)  │          │ (Container 3) │
└──────────────┘                └────────────────┘          └───────────────┘
Enter fullscreen mode Exit fullscreen mode

This multi-container approach introduces severe enterprise roadblocks:

  • Massive VRAM footprint: Hosting 2 or 3 distinct models requires multiple GPUs or high-end cloud instances (A100/H100), killing unit economics.
  • Network & serialization latency: Every hop between containers adds serialization, network overhead, and cold-start queueing.
  • The "Specialist Amnesia" trade-off: If you fine-tune an 8B model exclusively on accounting tool-calls (JSON/DSL), you destroy its vocabulary, nuance, and strategic writing capabilities (catastrophic forgetting). It becomes a glorified parser that cannot write an executive summary for a Board of Directors.

3. The Solution: The "Dual-Persona" Engine via LoRA Hot-Swapping

Instead of deploying multiple models, we leverage HuggingFace PEFT (Parameter-Efficient Fine-Tuning) on Liquid Foundation Models (LFM 2.5) to create a chameleon model.

flowchart TD
    subgraph UnifiedGPU["Single Model in VRAM: LFM2.5 (4-bit NF4)"]
        Base["LFM Base Model (Generalist C-Level Master)"]
        LoRA["LoRA Adapter (Accounting Specialist)"]
    end

    User["Analyst Question"] --> Step1["[1] LoRA OFF: Master Framing"]
    Step1 --> Step2["[2] LoRA ON: Tool Deduction"]
    Step2 --> ToolCall["Structured Tool Call:\n<|tool_call_start|>[compute_ratio(...)]<|tool_call_end|>"]

    subgraph LocalSandbox["Python Local Sandbox"]
        ToolCall --> PyExec["Python IEEE-754 Kernel\n(100% Deterministic)"]
        PyExec --> PyResult["Certified Result:\n{'ratio': 16.89, 'scale': 'PERCENT'}"]
    end

    PyResult --> Step3["[3] LoRA OFF: C-Level Executive Synthesis"]
    Step3 --> FinalReport["Board-Ready Executive Memo"]

How LoRA Toggling Works ?

When loading a quantized base model with a LoRA adapter via HuggingFace peft:

  1. The base model weights remain frozen in GPU memory.
  2. The low-rank matrices ($A$ and $B$) represent just a few megabytes of additional parameters.
  3. Toggling between the Accounting Specialist and the Generalist Master does not require reloading weights or unloading VRAM. It is as simple as flipping a computational pointer:
# Activate Accounting Specialist Mode:
model.enable_adapters()

# Deactivate back to Generalist C-Level Executive Mode:
model.disable_adapters()
Enter fullscreen mode Exit fullscreen mode

Latency cost: 0.00 milliseconds. The model shifts personality within the same process thread!


4. Building the Deterministic Engine (Code Walkthrough)

Let's look at the actual implementation extracted from our production testing pipeline.

Step 1: The Deterministic Python Arithmetic Sandbox

We define a strict, typed registry of financial functions evaluated locally via standard IEEE-754 floating-point operations:

# financial_tools.py
from typing import Any
import ast
import re

def compute_pct_change(poste: str, n1: float, n: float) -> dict[str, Any]:
    """Computes period-over-period percentage variation."""
    if n1 == 0:
        raise ValueError("Division by zero: base period N-1 is zero.")
    pct = round(((float(n) - float(n1)) / abs(float(n1))) * 100.0, 2)
    direction = "HAUSSE" if pct > 0 else ("BAISSE" if pct < 0 else "STABLE")
    return {
        "poste": poste,
        "n1": float(n1),
        "n": float(n),
        "variation_pct": pct,
        "sens": direction,
    }

def compute_ratio(poste: str, numerateur: float, denominateur: float, scale: str = "PERCENT") -> dict[str, Any]:
    """Computes profit margins, solvency, and liquidity ratios."""
    if denominateur == 0:
        raise ValueError("Division by zero: denominator is zero.")
    val = float(numerateur) / float(denominateur)
    res = round(val * 100.0, 2) if scale.upper() == "PERCENT" else round(val, 4)
    return {
        "poste": poste,
        "numerateur": float(numerateur),
        "denominateur": float(denominateur),
        "ratio": res,
        "scale": scale,
    }

def compute_sum(poste: str, values: list[float]) -> dict[str, Any]:
    """Computes balance sheet or OPEX aggregations."""
    total = round(sum(float(v) for v in values), 2)
    return {"poste": poste, "elements": values, "total": total}

TOOL_REGISTRY = {
    "compute_pct_change": compute_pct_change,
    "compute_ratio": compute_ratio,
    "compute_sum": compute_sum,
}

def execute_tool_call(tool_call_string: str) -> dict[str, Any]:
    """Safely parses and executes tool calls using AST."""
    match = re.search(r"(\w+)\((.*)\)", tool_call_string.strip(), re.DOTALL)
    if not match:
        raise ValueError(f"Invalid tool call syntax: {tool_call_string}")
    func_name, args_str = match.group(1), match.group(2)
    if func_name not in TOOL_REGISTRY:
        raise ValueError(f"Unknown tool: {func_name}")

    call_expr = ast.parse(f"{func_name}({args_str})", mode="eval")
    kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in call_expr.body.keywords}
    return TOOL_REGISTRY[func_name](**kwargs)
Enter fullscreen mode Exit fullscreen mode

Step 2: The Dual-Persona Orchestration Loop

Here is how the unified model executes the complete audit loop:

# dual_persona_runner.py
import re
import json
import torch
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

# 1. Load the unified foundation model in 4-bit (NF4)
BASE_ID = "LiquidAI/LFM2.5-8B-A1B"
ADAPTER_ID = "BtbAgent/lfm2.5-8B-specialiste-financier"

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
)

tokenizer = AutoTokenizer.from_pretrained(BASE_ID)
base_model = AutoModelForCausalLM.from_pretrained(
    BASE_ID,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
)

# 2. Attach the specialist LoRA adapter
model = PeftModel.from_pretrained(base_model, ADAPTER_ID)
model.eval()

def run_financial_audit(question: str):
    # -------------------------------------------------------------
    # PHASE A: SPECIALIST INFERENCE (LoRA ACTIVATED)
    # -------------------------------------------------------------
    model.enable_adapters()

    specialist_prompt = [
        {"role": "system", "content": "You are a deterministic financial accounting specialist. Deduce the formula and emit: <|tool_call_start|>[function_name(...)]<|tool_call_end|>"},
        {"role": "user", "content": question}
    ]

    # Generate tool call
    inputs = tokenizer.apply_chat_template(specialist_prompt, return_tensors="pt").to(model.device)
    raw_output = tokenizer.decode(model.generate(**inputs, max_new_tokens=256, temperature=0.0)[0])

    # Extract tool call
    match = re.search(r"<\|tool_call_start\|>(.*?)<\|tool_call_end\|>", raw_output, re.DOTALL)
    tool_call_str = match.group(1).strip()

    # -------------------------------------------------------------
    # PHASE B: DETERMINISTIC LOCAL PYTHON EXECUTION
    # -------------------------------------------------------------
    certified_result = execute_tool_call(tool_call_str)
    # e.g., {'poste': 'MARGE_EXPLOITATION', 'ratio': 16.89, 'scale': 'PERCENT'}

    # -------------------------------------------------------------
    # PHASE C: EXECUTIVE SYNTHESIS (LoRA DEACTIVATED)
    # -------------------------------------------------------------
    model.disable_adapters()  # Switches back to generalist mode in 0ms!

    master_prompt = [
        {"role": "system", "content": "Tu es Directeur d'Analyse Financière. Rédige une note exécutive C-Level en français."},
        {"role": "user", "content": f"Question: {question}\nChiffre certifié exact: {json.dumps(certified_result)}\nRédige l'analyse stratégique."}
    ]

    inputs = tokenizer.apply_chat_template(master_prompt, return_tensors="pt").to(model.device)
    final_memo = tokenizer.decode(model.generate(**inputs, max_new_tokens=1024, temperature=0.2)[0])

    return final_memo
Enter fullscreen mode Exit fullscreen mode

5. Live Production Benchmark Trace

Let's inspect the actual trace generated by our model evaluated on Google Colab:

Test Case: Operating Profit Margin

  • Analyst Query: "Calcule la marge opérationnelle si le résultat d'exploitation est de 245.0 M$ pour un CA de 1450.5 M$."

Step 1: Specialist Output (LoRA ON)

<think>
1. Objectif identifié : Analyse de rentabilité (MARGIN).
2. Poste financier ciblé : MARGE_EXPLOITATION.
3. Norme financière : Résultat d'exploitation / Chiffre d'affaires * 100.
4. Données extraites : Numérateur=245.0, Dénominateur=1450.5.
5. Outil officiel requis : compute_ratio avec scale='PERCENT'.
</think>
<|tool_call_start|>[compute_ratio(poste="MARGE_EXPLOITATION", numerateur=245.0, denominateur=1450.5)]<|tool_call_end|>
Enter fullscreen mode Exit fullscreen mode

Step 2: Python Deterministic Output (0 ms)

{
  "poste": "MARGE_EXPLOITATION",
  "numerateur": 245.0,
  "denominateur": 1450.5,
  "ratio": 16.89,
  "scale": "PERCENT"
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Executive C-Level Memo (LoRA OFF)

### NOTE DE SYNTHÈSE EXÉCUTIVE — COMITÉ D'INVESTISSEMENT

**1. Résumé Exécutif & Chiffre Clé**
* **Marge d'Exploitation Réalisée :** **16.89 %** (Résultat d'exploitation : 245.0 M$ / CA : 1 450.5 M$).
* **Statut de Performance :** Solide. Le ratio se situe au-dessus du standard sectoriel habituel (12 % - 15 %).

**2. Analyse de Rentabilité Opérationnelle**
La conversion de l'activité commerciale en résultat opérationnel démontre une excellente maîtrise des coûts fixes 
et de production. Avec 16.89 centimes de résultat d'exploitation générés par dollar de chiffre d'affaires, 
la société conserve un pricing power robuste.

**3. Recommandations Managériales**
1. **Poursuite du plan d'optimisation :** Viser un palier cible de 18 % à 12 mois par renégociation des contrats d'approvisionnement.
2. **Audit analytique :** Isoler la contribution par division pour s'assurer que la rentabilité n'est pas portée par un produit unique.
Enter fullscreen mode Exit fullscreen mode

Notice the result:

  • The math is 100% exact (16.89%, not 16.90%).
  • The syntax is clean (strict AST execution).
  • The writing tone is C-Level executive (French corporate style, structural depth, governance-focused).

6. The Sovereign Imperative: Zero Data Egress

Why does this matter beyond the math? Data sovereignty.

In corporate finance, the documents being parsed are not public blog posts. They are:

  • Pre-IPO EBITDA reconciliations.
  • Non-public M&A data rooms.
  • Confidential executive compensation schedules.
  • Proprietary cost breakdowns and margins.

Sending these documents to proprietary cloud APIs (OpenAI, Anthropic, Google) violates European GDPR, the EU AI Act, banking secrecy regulations, and enterprise confidentiality agreements.

Because the quantized LFM2.5-8B model has an active footprint of only 1.5B parameters per token, it requires just ~5.2 GB of VRAM in 4-bit NF4:

┌────────────────────────────────────────────────────────┐
│               AIR-GAPPED LAPTOP / ON-PREM LAN          │
│                                                        │
│  [Confidential M&A PDF] ──► [LFM 8B in VRAM (~5.2GB)]  │
│                                   │                    │
│                                   ▼                    │
│                        [Python Sandbox (IEEE-754)]     │
│                                   │                    │
│                                   ▼                    │
│                         [Internal Audit Report]        │
│                                                        │
│             ══════ ZERO DATA EGRESS (NO INTERNET) ═════ │
└────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

You can run this full pipeline on a standard consumer gaming laptop (RTX 4060 with 8GB VRAM) or an Apple Silicon Mac (M1-M4 with 16GB unified memory) with zero network traffic. Nothing leaves the machine.


Summary & What's Coming in Part 2

By decoupling semantic reasoning from mathematical computation and unifying them under a dynamic LoRA hot-swapping controller, we eliminate LLM arithmetic hallucinations while keeping infrastructure costs minimal.

Metric Traditional Cloud Multi-Agent Dual-Persona Sovereign Monolith
Mathematical Accuracy Probabilistic (~85-92%) 100% Deterministic (IEEE-754)
VRAM Consumption Multiple GPUs / Cloud APIs ~5.2 GB (Single Consumer GPU)
Persona Switching Latency 200 ms - 1.5 s (Network/RPC) 0.00 ms (In-Memory PEFT)
Data Privacy Cloud Data Egress Risk 100% Air-Gapped (Zero Egress)

In Part 2, we will pull back the curtain on the training recipe:

  • How we converted TAT-QA tables into a specialized financial Domain-Specific Language (DSL).
  • Fine-tuning with QLoRA using Unsloth.
  • And the bizarre bug we discovered during testing: "The Compensation Hallucination Trap" (what happens when an 8B specialist model panics over missing variables).

Resources & Open Source Artifacts

How is your team handling arithmetic reliability in production LLM workflows? Let's discuss in the comments below!


Enter fullscreen mode Exit fullscreen mode

Top comments (0)