FinQA Financial Agent
This project demonstrates training and running FinQA, a financial question-answering agent fine-tuned from Qwen3-4B-Instruct-2507 using rLLM. The agent uses specialized tools (SQL queries, table lookup, calculators) to perform multi-step reasoning over SEC 10-K financial statements, achieving 59.7% on Snorkel Finance Benchmark — outperforming Qwen3-235B (51.4%) and rivaling Gemini 2.5 Pro (60.6%).
Model Weights | Dataset | Blog Post
Overview
The FinQA project demonstrates:
- How to use rLLM's ToolAgent and ToolEnvironment for multi-step financial reasoning
- How to build domain-specific tools in rLLM
- How to train agents with GRPO using LLM-as-judge rewards
Quick Start
Installation
Follow rLLM installation, then install FinQA dependencies:
Dataset Preparation
Downloads the rLLM/finqa dataset and registers train/val/test splits:
Inference
Start a vLLM server and run the agent:
python -m vllm.entrypoints.openai.api_server \
--model rLLM/rLLM-FinQA-4B \
--host 0.0.0.0 \
--port 30000 \
--dtype bfloat16
python -m projects.finqa.run_finqa
Training
Set the required environment variables before training:
| Variable | Description |
|---|---|
OPENAI_API_KEY |
OpenAI API key for the reward judge |
PORTKEY_API_KEY |
Portkey gateway key for reward judge caching |
# verl backend (Qwen3-4B-Instruct-2507)
bash projects/finqa/train_finqa.sh
# tinker backend (Qwen3-30B-A3B-Instruct-2507, LoRA)
bash projects/finqa/train_finqa_tinker.sh
Code Reference
Financial Agent Runner
Main script for running financial reasoning:
import asyncio
import os
from transformers import AutoTokenizer
from projects.finqa.fin_qa_agent import FinQAAgent
from projects.finqa.fin_qa_environment import FinQAEnvironment
from rllm.data.dataset import DatasetRegistry
from rllm.engine.agent_execution_engine import AgentExecutionEngine
from rllm.utils import compute_pass_at_k
if __name__ == "__main__":
os.environ["TOKENIZERS_PARALLELISM"] = "true"
model_name = "rLLM/rLLM-FinQA-4B"
tokenizer = AutoTokenizer.from_pretrained(model_name)
sampling_params = {"temperature": 0.6, "top_p": 0.95}
engine = AgentExecutionEngine(
agent_class=FinQAAgent,
env_class=FinQAEnvironment,
engine_name="openai",
rollout_engine_args={"model": model_name, "base_url": "http://localhost:30000/v1", "api_key": "None"},
tokenizer=tokenizer,
sampling_params=sampling_params,
n_parallel_agents=100,
max_steps=20,
max_prompt_length=4096,
)
test_dataset = DatasetRegistry.load_dataset("finqa", "test")
if test_dataset is None:
print("Dataset not found, preparing dataset...")
from projects.finqa.prepare_finqa_data import prepare_finqa_data
_, _, test_dataset = prepare_finqa_data()
tasks = test_dataset.repeat(n=1)
results = asyncio.run(engine.execute_tasks(tasks))
compute_pass_at_k(results)
Training Script
FinQA training configuration:
import hydra
from rllm.agents.agent import Episode
from rllm.data.dataset import DatasetRegistry
from rllm.engine.rollout.rollout_engine import ModelOutput
from rllm.trainer.agent_trainer import AgentTrainer
from rllm.workflows.multi_turn_workflow import MultiTurnWorkflow
from rllm.workflows.workflow import TerminationEvent, TerminationReason
from .fin_qa_agent import FinQAAgent
from .fin_qa_environment import FinQAEnvironment
class FinQAWorkflow(MultiTurnWorkflow):
"""MultiTurnWorkflow with reward logging"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
async def run(self, task: dict, uid: str, **kwargs) -> Episode | None:
observation, info = await self.run_in_executor(self.reset, task=task, uid=uid)
self.agent.update_from_env(observation, 0, False, info)
# Calculate max context once (max_prompt_length + max_response_length)
max_model_len = self.rollout_engine.max_prompt_length + self.rollout_engine.max_response_length
min_response_buffer = 1000 # Minimum tokens to reserve for model response
for _ in range(1, self.max_steps + 1):
# Check if conversation is approaching context limit
if hasattr(self.rollout_engine, "chat_parser"):
# verl backend - use chat_parser
prompt = self.rollout_engine.chat_parser.parse(
self.agent.chat_completions,
add_generation_prompt=True,
is_first_msg=True,
)
prompt_length = len(self.rollout_engine.tokenizer.encode(prompt, add_special_tokens=False))
else:
# Tinker backend - use tokenizer directly
prompt_ids = self.rollout_engine.tokenizer.apply_chat_template(
self.agent.chat_completions,
add_generation_prompt=True,
tokenize=True,
)
prompt_length = len(prompt_ids)
if prompt_length > max_model_len - min_response_buffer:
raise TerminationEvent(TerminationReason.MAX_PROMPT_LENGTH_EXCEEDED)
output: ModelOutput = await self.rollout_engine.get_model_response(
self.agent.chat_completions,
application_id=uid,
enforce_max_prompt_length=False,
**kwargs,
)
response = output.text
action = self.agent.update_from_model(response)
# Store model_output on step for Tinker training (verl uses chat_completions)
if not hasattr(self.rollout_engine, "chat_parser") and self.agent.trajectory.steps:
self.agent.trajectory.steps[-1].model_output = output
next_obs, reward, done, info = await self.run_in_executor(self.env.step, action.action)
self.agent.update_from_env(next_obs, reward, done, info)
if output.finish_reason == "length":
raise TerminationEvent(TerminationReason.MAX_RESPONSE_LENGTH_EXCEEDED)
if done:
raise TerminationEvent(TerminationReason.ENV_DONE)
raise TerminationEvent(TerminationReason.MAX_TURNS_EXCEEDED)
def assign_episode_correctness(self, episode: Episode) -> None:
"""Use is_correct from Reward, before relying on default option"""
# Check if last step has is_correct from environment
if episode.trajectories and episode.trajectories[0].steps:
is_correct = episode.trajectories[0].steps[-1].info.get("is_correct")
if is_correct is not None:
episode.is_correct = is_correct
return
super().assign_episode_correctness(episode)
def collect_metrics(self, episode: Episode) -> None:
super().collect_metrics(episode)
# Added metadata from last step -> for wandb logging
if episode.trajectories and episode.trajectories[0].steps:
metadata = episode.trajectories[0].steps[-1].info.get("metadata", {})
episode.metrics.update(metadata)
@hydra.main(
config_path="pkg://rllm.trainer.config",
config_name="agent_ppo_trainer",
version_base=None,
)
def main(config):
train_dataset = DatasetRegistry.load_dataset("finqa", "train")
val_dataset = DatasetRegistry.load_dataset("finqa", "val")
config.rllm.workflow.use_workflow = True
trainer = AgentTrainer(
workflow_class=FinQAWorkflow,
workflow_args={
"agent_cls": FinQAAgent,
"env_cls": FinQAEnvironment,
"max_steps": 20,
},
config=config,
train_dataset=train_dataset,
val_dataset=val_dataset,
)
trainer.train()
if __name__ == "__main__":
main()
For detailed setup instructions, see the README in the finqa project directory.